@opengsd/gsd-core 1.20.0 → 1.20.1

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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/install.js +36 -40
  4. package/commands/gsd/new-milestone.md +2 -2
  5. package/commands/gsd/new-project.md +2 -2
  6. package/gsd-core/bin/gsd-tools.cjs +104 -70
  7. package/gsd-core/bin/lib/capability-registry.cjs +64 -64
  8. package/gsd-core/bin/lib/commands.cjs +19 -0
  9. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  10. package/gsd-core/bin/lib/init.cjs +7 -7
  11. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  12. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  13. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  14. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  15. package/gsd-core/bin/lib/phase.cjs +64 -15
  16. package/gsd-core/bin/lib/planning-snapshot.cjs +2 -2
  17. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -0
  18. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  19. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  20. package/gsd-core/bin/lib/state-transition.cjs +12 -5
  21. package/gsd-core/bin/lib/state.cjs +2 -2
  22. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  23. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  24. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  25. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  26. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  27. package/hooks/gsd-agent-isolation-guard.js +1 -1
  28. package/hooks/gsd-cursor-subagent-start.js +1 -1
  29. package/hooks/lib/isolation-sentinel.js +77 -2
  30. package/package.json +1 -1
  31. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  32. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  33. package/scripts/changeset/lint.cjs +28 -3
  34. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  35. package/scripts/lib/platform-conformance-tier.generated.cjs +4 -0
  36. package/scripts/merge-commit-pr.cjs +186 -0
  37. package/scripts/release-ancestry.cjs +86 -0
  38. package/scripts/release-pipeline-pr.cjs +92 -0
  39. package/scripts/require-issue-link-policy.cjs +18 -9
  40. package/scripts/sync-next-version.cjs +11 -2
  41. package/skills/gsd-new-milestone/SKILL.md +1 -1
  42. package/skills/gsd-new-project/SKILL.md +1 -1
  43. package/vscode/package.json +1 -1
@@ -42,7 +42,7 @@ const coreUtilsMod = require("./core-utils.cjs");
42
42
  const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
44
44
  const phaseIdMod = require("./phase-id.cjs");
45
- const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
45
+ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, phaseNumberTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
47
  const phaseIdDisplayMod = require("./phase-id-display.cjs");
48
48
  // #4304: reuse the Phase Id Display Module's milestone/phase
@@ -61,7 +61,10 @@ const planningScopeMod = require("./planning-scope.cjs");
61
61
  const { SCOPE } = planningScopeMod;
62
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
63
63
  const roadmapParserMod = require("./roadmap-parser.cjs");
64
- const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo, scanMilestonePhaseIds,
64
+ const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo,
65
+ // #5679: the STATE.md milestone read getMilestoneInfo itself uses, and the
66
+ // archive-aware owner of a ROADMAP offset.
67
+ readStateMilestoneScalar, classifyMilestoneScalar, milestoneVersionAtOffset, blankCodeAndComments, scanMilestonePhaseIds,
65
68
  // #4304: every recognized milestone heading in the document,
66
69
  // not just the active one — see bracketProgressSectionOwnedByOtherMilestone.
67
70
  listMilestoneHeadings, selectMilestoneHeading,
@@ -496,6 +499,9 @@ function cmdFindPhase(cwd, phase, raw) {
496
499
  const directoryContext = phaseDirectoryOptions(cwd);
497
500
  const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
498
501
  const { normalized, convention } = lookup;
502
+ // #5676: `lookup.convention` is only ever 'bracket'; the directory token grammar
503
+ // (`03-02-beta` -> `03-02` + `beta`) needs the repo's own convention.
504
+ const tokenConvention = resolvePhaseIdConvention(cwd);
499
505
  const notFound = {
500
506
  found: false,
501
507
  directory: null,
@@ -561,7 +567,7 @@ function cmdFindPhase(cwd, phase, raw) {
561
567
  && BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
562
568
  const dirMatch = isBracketDirectory
563
569
  ? null
564
- : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
570
+ : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i')) || match.match(new RegExp(`^(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i'));
565
571
  const customParts = custom ? customPhaseDirectoryParts(match, custom) : null;
566
572
  const phaseNumber = isBracketDirectory
567
573
  ? phaseNumberForMatch(match, usedBareFallback, convention)
@@ -2187,6 +2193,34 @@ function isPhaseTokenWithin(token, ancestor) {
2187
2193
  const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
2188
2194
  return a.every((segment, i) => canon(segment) === canon(t[i]));
2189
2195
  }
2196
+ /**
2197
+ * #5679: the `phase insert` error for a phase that exists in the ROADMAP but
2198
+ * outside the current milestone. Names the milestone that holds it (archive
2199
+ * aware, never the current one) and the current milestone, saying "STATE.md's"
2200
+ * only when STATE.md's `milestone:` is where that version came from.
2201
+ */
2202
+ function otherMilestonePhaseMessage(rawContent, foundAt, cwd, afterPhase) {
2203
+ let stateVersion = null;
2204
+ try {
2205
+ const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'STATE.md'));
2206
+ const scalar = stateRaw === null ? null : readStateMilestoneScalar(stateRaw);
2207
+ if (scalar !== null)
2208
+ stateVersion = classifyMilestoneScalar(scalar).version;
2209
+ }
2210
+ catch { /* unreadable STATE.md: getMilestoneInfo's own fallback below decides */ }
2211
+ const derivedVersion = getMilestoneInfo(cwd).value?.version ?? null;
2212
+ const current = stateVersion ?? derivedVersion;
2213
+ const owner = milestoneVersionAtOffset(rawContent, foundAt);
2214
+ const ownerClause = owner && owner.toLowerCase() !== current?.toLowerCase()
2215
+ ? `is in milestone ${owner}`
2216
+ : 'is outside the current milestone';
2217
+ let currentClause = 'the current milestone does not include it';
2218
+ if (stateVersion)
2219
+ currentClause = `STATE.md's current milestone is ${stateVersion}`;
2220
+ else if (derivedVersion)
2221
+ currentClause = `the current milestone is ${derivedVersion}`;
2222
+ return `Phase ${afterPhase} ${ownerClause}, but ${currentClause}. phase insert only targets phases in the current milestone.`;
2223
+ }
2190
2224
  function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
2191
2225
  if (!afterPhase || !description) {
2192
2226
  error('after-phase and description required for phase insert');
@@ -2257,7 +2291,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2257
2291
  const anyHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}\\d`, 'i');
2258
2292
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
2259
2293
  // #4304: bracket identities live in headings only, so a bracket ROADMAP never takes the legacy bullet-insertion branch — a bullet-only bracket ROADMAP falls through to the checklist-refusal path below instead.
2260
- const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
2294
+ const liveCurrent = blankCodeAndComments(content);
2295
+ const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(liveCurrent) && !roadmapHasHeadingPhases;
2261
2296
  if (bracketContext && !bracketHeading) {
2262
2297
  const searchedRanges = bracketSectionRanges?.details
2263
2298
  ? 'primary and Phase Details ranges'
@@ -2266,9 +2301,19 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2266
2301
  }
2267
2302
  if (!headingMatch && !isBulletStyle) {
2268
2303
  const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2269
- if (checklistPattern.test(content)) {
2304
+ if (checklistPattern.test(liveCurrent)) {
2270
2305
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
2271
2306
  }
2307
+ // #5679: this whole-file probe only chooses the error message; insert
2308
+ // stays current-milestone-only and never writes to another milestone.
2309
+ // Fenced blocks and HTML comments are masked (same length, offsets stay aligned) so example text never counts as a phase.
2310
+ const liveContent = blankCodeAndComments(rawContent);
2311
+ const headingOffset = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(liveContent).find((h) => targetPattern.test(h.text))?.offset;
2312
+ const checklistOffset = checklistPattern.exec(liveContent)?.index;
2313
+ const foundOffsets = [headingOffset, checklistOffset].filter((o) => o !== undefined);
2314
+ if (foundOffsets.length > 0) {
2315
+ error(otherMilestonePhaseMessage(rawContent, Math.min(...foundOffsets), cwd, afterPhase));
2316
+ }
2272
2317
  error(`Phase ${afterPhase} not found in ROADMAP.md`);
2273
2318
  }
2274
2319
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -2333,7 +2378,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2333
2378
  let roadmapEntry;
2334
2379
  if (isBulletStyle) {
2335
2380
  const boldBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', { bold: 'required' });
2336
- const useBold = boldBulletPattern.test(content);
2381
+ const useBold = boldBulletPattern.test(liveCurrent);
2337
2382
  const phaseLabel = useBold
2338
2383
  ? `**Phase ${_decimalPhase}: ${description}**`
2339
2384
  : `Phase ${_decimalPhase}: ${description}`;
@@ -2356,11 +2401,12 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2356
2401
  // true line-content boundary so bulletLineEnd lands cleanly before the
2357
2402
  // terminator.
2358
2403
  const targetBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`, 'i');
2359
- const bulletMatchResult = rawContent.match(targetBulletPattern);
2404
+ // Match against the masked text (same length, offsets aligned) so a fenced or commented example bullet is never the splice anchor; splice into the original text at the masked match index.
2405
+ const bulletMatchResult = blankCodeAndComments(rawContent).match(targetBulletPattern);
2360
2406
  if (!bulletMatchResult) {
2361
2407
  error(`Could not find Phase ${afterPhase} bullet line`);
2362
2408
  }
2363
- const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
2409
+ const bulletLineEnd = bulletMatchResult.index + bulletMatchResult[0].length;
2364
2410
  const afterBullet = rawContent.slice(bulletLineEnd);
2365
2411
  // #5599: land after the last bullet inside the allocation family's
2366
2412
  // subtree, not before the first following bullet of any number (which is
@@ -6424,6 +6470,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6424
6470
  // where the roadmap would give `2` and a slugified title. Promoting the
6425
6471
  // roadmap without this would silently change the reported value on
6426
6472
  // every aligned project, which is the majority case.
6473
+ // #5676: the numeric phase-token grammar follows the convention, so a
6474
+ // milestone-prefixed `03-02-beta` is phase `3-02`, not phase `3`.
6475
+ const phaseConvention = resolvePhaseIdConvention(cwd);
6427
6476
  let diskNextNum = null;
6428
6477
  let diskNextName = null;
6429
6478
  let roadmapNextNum = null;
@@ -6442,7 +6491,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6442
6491
  if (roadmapContent !== null) {
6443
6492
  try {
6444
6493
  const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
6445
- const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})`, 'gi');
6494
+ const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})`, 'gi');
6446
6495
  let cm;
6447
6496
  while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
6448
6497
  if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(cm[0]))
@@ -6472,7 +6521,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6472
6521
  for (const dir of dirs) {
6473
6522
  const custom = phaseIdMod.customPhaseQuery(dir, directoryContext);
6474
6523
  const owner = custom && customDirectoryOwner(dir, custom);
6475
- const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
6524
+ const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${phaseNumberTokenSource(phaseConvention)})-?(.*)`, 'i'));
6476
6525
  if (dm) {
6477
6526
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
6478
6527
  if (isSentinelPhaseId(dm[1]))
@@ -6535,8 +6584,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6535
6584
  // checkbox branch widens, and only to the separators #2199 already
6536
6585
  // accepts. The two branches keep separate capture groups, normalized
6537
6586
  // just below the loop.
6538
- const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
6539
- `|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
6587
+ const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
6588
+ `|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
6540
6589
  let pm;
6541
6590
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
6542
6591
  // #4078: normalize the two alternation branches' captures (heading
@@ -6636,7 +6685,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6636
6685
  // Without it, this lowest-outstanding override was blind to dash-grammar
6637
6686
  // rows and could not correct an out-of-order completion on the same
6638
6687
  // mixed-grammar roadmaps that broke stage 2.
6639
- const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
6688
+ const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
6640
6689
  let cbm;
6641
6690
  let lowestOutstanding = null;
6642
6691
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
@@ -6685,7 +6734,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6685
6734
  // pin would all rewind it. A first-time completion (box unchecked, or
6686
6735
  // STATE not past N, #2028 out-of-order) still transitions.
6687
6736
  const stateAlreadyPast = phaseWasAlreadyComplete
6688
- && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
6737
+ && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath, phaseConvention);
6689
6738
  let stateContent = originalStateContent;
6690
6739
  // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
6691
6740
  // shape/name, Status, Current Plan, Last Activity + Description, and
@@ -6715,7 +6764,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6715
6764
  const nextInfo = findPhaseInternal(cwd, nextPhaseNum, undefined, directoryContext);
6716
6765
  if (nextInfo && nextInfo.found && nextInfo.directory) {
6717
6766
  const { value } = (0, phase_status_cjs_1.phaseStatus)(node_path_1.default.join(cwd, nextInfo.directory), {
6718
- convention: resolvePhaseIdConvention(cwd),
6767
+ convention: phaseConvention,
6719
6768
  });
6720
6769
  nextPhaseDisk = { status: value.status, planCount: value.planCount, summaryCount: value.summaryCount };
6721
6770
  }
@@ -608,7 +608,7 @@ function buildRoadmapPhaseCheckboxesField(roadmapPath, convention, directoryCont
608
608
  // group 2 and the legacy repo compiles a byte-identical source.
609
609
  // The entry prefix (marker, box, bold token) is the phase-entry grammar's (#5764),
610
610
  // the same one `buildNotStartedPhaseVariants` composes; group 1 is the box.
611
- const checkboxRe = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}0*(${convention === 'bracket' ? PHASE_NUMBER_TOKEN_SOURCE : phaseIdMod.phaseScanTokenSource(directoryContext)})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi', { state: 'captured' });
611
+ const checkboxRe = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}0*(${convention === 'bracket' ? PHASE_NUMBER_TOKEN_SOURCE : phaseIdMod.phaseScanTokenSource(directoryContext, convention)})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi', { state: 'captured' });
612
612
  const value = {};
613
613
  let m;
614
614
  while ((m = checkboxRe.exec(content)) !== null) {
@@ -905,7 +905,7 @@ function buildCurrentMilestoneRoadmapPhaseIdsField(cwd, roadmapPath, convention,
905
905
  // pinned to this site by name) would catch that narrowing, since the census
906
906
  // only pins `phaseHeadingPrefixSrcFor`'s own output, not this regex's tail.
907
907
  // Kept as its own composition to preserve this exact byte-for-behavior.
908
- const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, Boolean(bracketGroup))}(${convention !== 'bracket' ? phaseIdMod.phaseScanTokenSource(directoryContext) : PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
908
+ const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, Boolean(bracketGroup))}(${convention !== 'bracket' ? phaseIdMod.phaseScanTokenSource(directoryContext, convention) : PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
909
909
  const value = [];
910
910
  for (const m of scoped.matchAll(phasePattern)) {
911
911
  const bracketId = bracketGroup ? m[1] : undefined;
@@ -1736,6 +1736,31 @@ function scanArchiveSpans(content, active) {
1736
1736
  spans.push(...plainClosedSections(content, overlapsActive, activeVersion));
1737
1737
  return { spans, inActive, ambiguous };
1738
1738
  }
1739
+ /**
1740
+ * #5679: the milestone version that owns `offset`. An offset inside a `<details>`
1741
+ * archive (or a plain closed-milestone section) belongs to that block's
1742
+ * `<summary>` (or heading) version, wherever the block sits relative to the
1743
+ * active heading; any other offset belongs to the last milestone heading before
1744
+ * it. Blocks come from `scanArchiveSpans` (fence/comment-aware, depth-paired),
1745
+ * innermost first, so a notes block inside an archive does not hide the archive.
1746
+ * Null when neither names a version.
1747
+ */
1748
+ function milestoneVersionAtOffset(content, offset) {
1749
+ const containing = scanArchiveSpans(content, []).spans
1750
+ .filter((span) => span.start <= offset && offset < span.end)
1751
+ .sort((a, b) => b.start - a.start);
1752
+ for (const span of containing) {
1753
+ const head = content.slice(span.start, span.start + 1200);
1754
+ // Only a <details> span owns its <summary>; a plain closed-milestone section must not borrow a later block's.
1755
+ const summary = /^<details\b/i.test(head) ? /<summary[^>]*>([\s\S]{0,1000}?)<\/summary>/i.exec(head) : null;
1756
+ const text = summary ? summary[1].replace(/<[^>]{0,200}>/g, '') : head.split('\n', 1)[0];
1757
+ const version = text.match(VERSION_TOKEN)?.[0];
1758
+ if (version)
1759
+ return version;
1760
+ }
1761
+ const preceding = listMilestoneHeadings(content.slice(0, offset));
1762
+ return preceding.length > 0 ? preceding[preceding.length - 1].version : null;
1763
+ }
1739
1764
  /**
1740
1765
  * #5553: hide every archived `<details>` block, and every closed milestone
1741
1766
  * written as a plain heading section, behind a placeholder; `restore` puts them
@@ -2915,6 +2940,10 @@ module.exports = {
2915
2940
  // #4304: destructive ROADMAP writers classify details blocks through
2916
2941
  // the same closed-summary rule as stripClosedMilestoneDetails.
2917
2942
  isClosedMilestoneDetails,
2943
+ // #5679: the owning milestone version of a ROADMAP offset, archive-aware.
2944
+ milestoneVersionAtOffset,
2945
+ // #5679: same-length copy with fenced blocks, code spans and HTML comments blanked.
2946
+ blankCodeAndComments,
2918
2947
  // #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
2919
2948
  // bracket roadmap migrator's "phase-like but unparsed" refusal
2920
2949
  // (src/roadmap-upgrade.cts) can gate on it instead of a private, looser