@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
@@ -13,25 +13,29 @@ const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const clock_cjs_1 = require("./clock.cjs");
15
15
  const pattern_cjs_1 = require("./pattern.cjs");
16
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
16
17
  const text_lines_cjs_1 = require("./text-lines.cjs");
17
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
19
  const ioMod = require("./io.cjs");
19
20
  const { output, error, formatDiagnosticToken, declineNoOp } = ioMod;
20
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
22
  const phaseIdMod = require("./phase-id.cjs");
22
- const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
23
+ const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
23
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
25
  const phaseLocatorMod = require("./phase-locator.cjs");
25
- const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
26
+ const { phaseDirectoryOptions, findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup, } = phaseLocatorMod;
26
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
28
  const planningScopeMod = require("./planning-scope.cjs");
28
29
  const { SCOPE } = planningScopeMod;
29
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
31
  const roadmapParserModule = require("./roadmap-parser.cjs");
31
- const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows, hasPhaseListingTableHeader } = roadmapParserModule;
32
+ const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, checklistBulletExtent, phaseChecklistBullet: phaseChecklistBulletFor, withPhaseSection, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows, hasPhaseListingTableHeader } = roadmapParserModule;
33
+ /** Every phase-list entry line, any phase: the rows between two of them belong to the first. */
34
+ const ANY_PHASE_ENTRY_LINE = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t][^\\r\\n]*$', 'gi');
32
35
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
33
36
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
34
37
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
38
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
35
39
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
36
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
41
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -154,7 +158,7 @@ function searchPhaseInContent(content, escapedPhase, phaseNum, convention) {
154
158
  // Fallback: check if phase exists in summary list but missing detail section
155
159
  // A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket
156
160
  // repo gains the bracket-ID form and nothing else.
157
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i');
161
+ const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'i', { bold: 'required' });
158
162
  const checklistMatch = content.match(checklistPattern);
159
163
  if (checklistMatch) {
160
164
  return {
@@ -354,7 +358,7 @@ const occurrenceKey = (num, bracketId) => {
354
358
  * `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
355
359
  * same enrichment, not a second derivation.
356
360
  */
357
- function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
361
+ function collectAnalyzePhases(cwd, content, phasesDir, phaseDirNames, convention, directoryContext) {
358
362
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
359
363
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
360
364
  // #612: CAPTURING intro under the bracket convention — group 1 is the
@@ -375,7 +379,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
375
379
  // (src/markdown-sectionizer.cts:453) so a legitimately-indented heading that
376
380
  // matched before this fix still matches after it.
377
381
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
378
- const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
382
+ const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}(${directoryContext.phaseNaming === 'custom' && convention !== 'bracket' ? phaseIdMod.phaseScanTokenSource(directoryContext) : '[A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*'})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
379
383
  // The capturing intro inserts the bracket id at group 1 only under the
380
384
  // bracket convention; the token and name shift by the same offset.
381
385
  const G = convention === 'bracket' ? 1 : 0;
@@ -413,7 +417,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
413
417
  const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
414
418
  const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
415
419
  // Check completion on disk
416
- const normalized = normalizePhaseName(phaseNum);
420
+ const lookup = resolvePhaseDirectoryLookup(cwd, phaseNum, undefined, directoryContext);
417
421
  let diskStatus = phase_status_cjs_1.DISK_STATUS.NO_DIRECTORY;
418
422
  let planCount = 0;
419
423
  let summaryCount = 0;
@@ -447,9 +451,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
447
451
  // That is verbatim the asymmetry the note above the W026 rule says this PR
448
452
  // closed — the directory read widens with the heading read, or every bracket
449
453
  // phase resolves to nothing.
450
- // Upstream centralized this choice in `matchPhaseDirs`; thread the same
451
- // convention into that owner rather than reviving the primitive `.find()`.
452
- const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0];
454
+ // The locator adapter applies the canonical bracket spelling first and the
455
+ // migration-window legacy spelling only when the canonical pass misses.
456
+ const dirMatch = matchPhaseDirsForLookup(phaseDirNames, lookup, undefined, directoryContext).matches[0];
453
457
  if (dirMatch) {
454
458
  const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch), convention);
455
459
  planCount = counts.planCount;
@@ -494,9 +498,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
494
498
  // checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
495
499
  // reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
496
500
  // Decision 3).
497
- const checkboxPattern = new RegExp(`^[ \\t]*-[ \\t]*\\[(x| )\\][ \\t]*(?:\\*\\*)?${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'im');
501
+ const checkboxPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum, directoryContext)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'im', { state: 'captured' });
498
502
  const checkboxMatch = content.match(checkboxPattern);
499
- const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
503
+ const roadmapComplete = checkboxMatch ? (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(checkboxMatch[1]) : false;
500
504
  const headingPhase = {
501
505
  number: phaseNum,
502
506
  name: phaseName,
@@ -528,7 +532,8 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
528
532
  detailKeys.add(occurrenceKey(tr.id));
529
533
  if (seen.has(stripPadA(tr.id)))
530
534
  continue;
531
- const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id), convention).matches[0];
535
+ const tableLookup = resolvePhaseDirectoryLookup(cwd, tr.id, undefined, directoryContext);
536
+ const dirMatchA = matchPhaseDirsForLookup(phaseDirNames, tableLookup, undefined, directoryContext).matches[0];
532
537
  let tPlanCount = 0;
533
538
  let tSummaryCount = 0;
534
539
  let tHasContext = false;
@@ -588,6 +593,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
588
593
  const phasesDir = planningPaths(cwd).phases;
589
594
  // #612: resolve once per command and thread the same reading through both
590
595
  // the scoped scan and any fallback scan.
596
+ const directoryContext = phaseDirectoryOptions(cwd, undefined, rawContent);
591
597
  const convention = resolvePhaseIdConvention(cwd);
592
598
  const G = convention === 'bracket' ? 1 : 0;
593
599
  // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
@@ -605,7 +611,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
605
611
  // Scan the scoped milestone window for phase-detail headings and enrich each
606
612
  // with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
607
613
  // the SAME enrichment re-runs on the fallback below — not a second copy.
608
- let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention);
614
+ let collected = collectAnalyzePhases(cwd, content, phasesDir, _phaseDirNames, convention, directoryContext);
609
615
  let phases = collected.phases;
610
616
  let detailKeys = collected.detailKeys;
611
617
  // #5118: carried from every phaseStatus read this command makes.
@@ -631,7 +637,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
631
637
  // populated, flagged result.
632
638
  if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
633
639
  const fallbackContent = stripShippedMilestones(rawContent);
634
- const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention);
640
+ const fallbackCollection = collectAnalyzePhases(cwd, fallbackContent, phasesDir, _phaseDirNames, convention, directoryContext);
635
641
  if (fallbackCollection.phases.length > 0) {
636
642
  collected = fallbackCollection;
637
643
  phases = collected.phases;
@@ -664,7 +670,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
664
670
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
665
671
  // #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
666
672
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
667
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)`, 'gi');
673
+ const customIds = directoryContext.phaseNaming === 'custom' && convention !== 'bracket';
674
+ const checklistPattern = new RegExp(checklistEntrySrc(convention, true, customIds), 'gim');
668
675
  // #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket
669
676
  // map. The map was first-wins on the bare token, so of two checklist entries
670
677
  // sharing a token across brackets the FIRST one's bracket id classified BOTH:
@@ -687,7 +694,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
687
694
  // `checklistPattern` — adding one would shift every downstream `1 + G` /
688
695
  // `G` index this loop and `missingDetails` below already depend on.
689
696
  // Reading it off the full match text instead is index-safe.
690
- checklistOccurrences.push({ token, bracketId, checked: /\[x\]/i.test(checklistMatch[0]) });
697
+ checklistOccurrences.push({ token, bracketId, checked: (0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(checklistMatch[0]) });
691
698
  }
692
699
  // #4899: `collectAnalyzePhases` only recognises `### Phase N:` headings and
693
700
  // progress-table rows — the shape `templates/roadmap.md` itself emits
@@ -724,8 +731,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
724
731
  for (const occ of checklistOccurrences) {
725
732
  if (isSentinelPhase(occ.token, occ.bracketId))
726
733
  continue;
727
- const normalized = normalizePhaseName(occ.token);
728
- const dirMatch = matchPhaseDirs(_phaseDirNames, normalized, convention).matches[0];
734
+ const checklistLookup = resolvePhaseDirectoryLookup(cwd, occ.token, undefined, directoryContext);
735
+ const dirMatch = matchPhaseDirsForLookup(_phaseDirNames, checklistLookup, undefined, directoryContext).matches[0];
729
736
  let diskStatus = 'no_directory';
730
737
  let planCount = 0;
731
738
  let summaryCount = 0;
@@ -829,6 +836,54 @@ function cmdRoadmapAnalyze(cwd, raw) {
829
836
  plan_count: p.plan_count,
830
837
  summary_count: p.summary_count,
831
838
  }));
839
+ // #5221: ship's completeness gate. Phase number (zero-padding folded per segment, as ship.md's `n`)
840
+ // -> true only when EVERY record of it reads complete: each checklist entry (any bracket — a shared
841
+ // token fails closed) and the phase-table Status cell. Absent = no record. The table is read from the
842
+ // exact slice `update-plan-progress` writes (editProgressTableSlice), so reader and writer agree.
843
+ const roadmapRecord = {};
844
+ const fold = (token, done) => {
845
+ const k = foldPhaseId(token);
846
+ roadmapRecord[k] = (roadmapRecord[k] ?? true) && done;
847
+ };
848
+ for (const o of checklistOccurrences)
849
+ if (!isSentinelPhase(o.token, o.bracketId))
850
+ fold(o.token, o.checked);
851
+ // phase_naming: custom ids (`- [ ] **Phase AUTH: …**`, as phase add writes them) are records too.
852
+ const isCustomId = (t) => customIds && !/^[A-Za-z]?\d/.test(t);
853
+ if (customIds) {
854
+ for (const m of effectiveContent.matchAll(new RegExp(checklistEntrySrc(convention, false, true), 'gim'))) {
855
+ if (isCustomId(m[1]))
856
+ fold(m[1], (0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(m[0]));
857
+ }
858
+ }
859
+ // A phase table that does not parse (e.g. a ragged row) makes the record unknown: null, so ship stops.
860
+ let tableUnreadable = false;
861
+ const archivedForRecord = roadmapParserModule.maskArchivedDetails(rawContent, cwd);
862
+ editProgressTableSlice(archivedForRecord.visible, (scoped) => {
863
+ // Same table the writer picks (findPhaseTableHeader); a slice without a Plans column keeps the whole-slice Phase + Status scan.
864
+ const planned = findPhaseTableHeader(scoped);
865
+ const header = planned !== -1 ? planned : scoped.search(/^[ \t]*(?=.*\|[ \t]*Phase[ \t]*\|)(?=.*\|[ \t]*Status[ \t]*\|)\|.*$/m);
866
+ if (header === -1)
867
+ return scoped;
868
+ const table = (0, markdown_table_cjs_1.parseMarkdownTable)(scoped.slice(header));
869
+ // A phase table whose Phase/Status headers are not recognised (other case or wording) is unknown: null, so ship stops.
870
+ if (!table.ok || !table.value.columns.includes('Phase') || !table.value.columns.includes('Status')) {
871
+ tableUnreadable = true;
872
+ return scoped;
873
+ }
874
+ const wantMilestone = table.value.columns.includes('Milestone')
875
+ ? (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(roadmapParserModule.getMilestoneInfo(cwd).value?.version)
876
+ : null;
877
+ for (const r of table.value.rows) {
878
+ if (wantMilestone !== null && (wantMilestone === '' || (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(r['Milestone']) !== wantMilestone))
879
+ continue;
880
+ const cell = (r['Phase'] ?? '').trim();
881
+ const token = cell.match(/^[A-Za-z]?\d\S*?(?=\.?(?:\s|$))/)?.[0] ?? cell.match(new RegExp(`^${CUSTOM_PHASE_ID_SRC}?(?=\\.?(?:\\s|$))`))?.[0];
882
+ if (token && (/^[A-Za-z]?\d/.test(token) ? !isSentinelPhase(token) : isCustomId(token)))
883
+ fold(token, (0, phase_status_cjs_1.parseRoadmapStatusCell)(r['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE);
884
+ }
885
+ return scoped;
886
+ }, archivedForRecord.activeRange);
832
887
  const result = {
833
888
  milestones,
834
889
  phases,
@@ -859,6 +914,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
859
914
  // #4757: phases whose ROADMAP checkbox disagrees with `disk_status`
860
915
  // (`roadmap_complete` vs `disk_status === "complete"`); always an array.
861
916
  checkbox_conflict: checkboxConflict,
917
+ roadmap_record: tableUnreadable ? null : roadmapRecord,
862
918
  missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
863
919
  // #3184/#3165: distinguishes a genuinely empty milestone (`scope:
864
920
  // "complete"`, `phase_count: 0`) from a window that could not be fully
@@ -932,14 +988,57 @@ function cmdRoadmapMilestoneScope(cwd, raw) {
932
988
  output({ scope, phases, phase_count: phases.length }, raw, undefined);
933
989
  }
934
990
  // ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
991
+ /** #5221: phase-id fold (leading zeros stripped per segment) keying roadmap_record; ship.md's jq `def n` folds identically (pinned by a test). */
992
+ function foldPhaseId(token) {
993
+ return token.split('.').map((s) => s.replace(/^0+(?=\d)/, '')).join('.');
994
+ }
995
+ /** #5221: whether offset `idx` of `text` sits inside a bare <details> block (an open-attribute wrapper is the active milestone, not shipped) that has not closed. */
996
+ function insideOpenDetails(text, idx) {
997
+ const prior = text.slice(0, idx).toLowerCase();
998
+ return prior.lastIndexOf('<details>') > prior.lastIndexOf('</details>');
999
+ }
1000
+ /** #5221: a `phase_naming: custom` id (`Phase AUTH:`). */
1001
+ const CUSTOM_PHASE_ID_SRC = '[A-Za-z][\\w-]{0,63}';
1002
+ /**
1003
+ * #5221: source of a `- [ ] **Phase N:` checklist entry, the one shape `roadmap analyze` reads and
1004
+ * phase add/insert anchor on. Token is the last group; `custom` also admits a custom id before `:`.
1005
+ */
1006
+ function checklistEntrySrc(convention, capturing = false, custom = false) {
1007
+ // phase-id-owner: uses [.-] (dot-or-dash) separator variant, not the canonical dot-only token; swapping to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
1008
+ const numeric = '[A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*';
1009
+ // numeric must end at a word edge, else `P1-setup` matches as `P1` before the custom alternative is tried.
1010
+ const token = custom ? `(?:${numeric}(?![\\w-])|${CUSTOM_PHASE_ID_SRC}(?=:))` : numeric;
1011
+ return `${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturing)}(${token})`;
1012
+ }
1013
+ /**
1014
+ * #5221: offset of the phase table's header row — the first table row whose cells
1015
+ * name `Phase` and `Plans Complete` (or `Plans`), compared case-insensitively. The one
1016
+ * predicate `editFromPhaseTable` (writer) and `roadmap analyze`'s `roadmap_record` (reader)
1017
+ * both pick the table by, so they never disagree on which table is the phase table. -1: none.
1018
+ */
1019
+ function findPhaseTableHeader(text) {
1020
+ for (const m of text.matchAll(/^[ \t]*\|.*$/gm)) {
1021
+ const cells = m[0].split('|').map((c) => c.trim().toLowerCase());
1022
+ if (cells.includes('phase') && (cells.includes('plans complete') || cells.includes('plans')))
1023
+ return m.index;
1024
+ }
1025
+ return -1;
1026
+ }
1027
+ function editFromPhaseTable(scoped, edit) {
1028
+ // #5221: Status/Completed bind to the first table carrying that column; start
1029
+ // at the phase table so an earlier versions table is never the target. A Progress
1030
+ // slice with no Plans column at all keeps the whole-slice scan.
1031
+ const at = Math.max(findPhaseTableHeader(scoped), 0);
1032
+ return scoped.slice(0, at) + edit(scoped.slice(at));
1033
+ }
935
1034
  /**
936
1035
  * Scope a ROADMAP.md content string down to its "Progress table" writable
937
1036
  * slice, run `edit` against just that slice, then splice the result back into
938
1037
  * the original content (ADR-2143 §7). Layered scoping:
939
- * 1. Milestone scope — everything after the LAST `</details>` close tag
940
- * (mirrors `replaceInCurrentMilestone`), so a same-numbered phase row in
941
- * an archived milestone is never touched.
942
- * 2. Heading scope — within that milestone slice, the `## Progress` heading
1038
+ * 1. Milestone scope — the caller has already hidden archived `<details>`
1039
+ * blocks (`maskArchivedDetails`, #5553), so a same-numbered phase row in
1040
+ * an archived milestone is never seen here.
1041
+ * 2. Heading scope — within the visible text, the `## Progress` heading
943
1042
  * section (up to the next `#`/`##` heading) when present, else the whole
944
1043
  * milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
945
1044
  * read-side scoping, #2012 decoy avoidance — a differently-headed table
@@ -948,29 +1047,19 @@ function cmdRoadmapMilestoneScope(cwd, raw) {
948
1047
  * found within the scoped slice) simply returns its input unchanged, mirroring
949
1048
  * the prior regex `.replace()`'s no-match-is-a-no-op semantics.
950
1049
  */
951
- function editProgressTableSlice(content, edit) {
952
- const lastDetailsClose = content.lastIndexOf('</details>');
953
- const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + '</details>'.length;
954
- const before = content.slice(0, milestoneOffset);
955
- const milestoneSlice = content.slice(milestoneOffset);
956
- const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
957
- if (!progressMatch || progressMatch.index === undefined) {
958
- return before + edit(milestoneSlice);
959
- }
960
- const headingOffset = progressMatch.index;
961
- const beforeHeading = milestoneSlice.slice(0, headingOffset);
962
- const fromHeading = milestoneSlice.slice(headingOffset);
963
- const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
964
- const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
965
- const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
966
- return before + beforeHeading + edit(scoped) + after;
1050
+ function editProgressTableSlice(content, edit, active = null) {
1051
+ // #5762: the Progress section is the owner's (`editRoadmapProgress`, the write side of the
1052
+ // rule every reader uses; the first one inside the active milestone's range wins, #5553).
1053
+ // A headingless document is edited whole, from the phase table's header.
1054
+ return (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (scoped) => editFromPhaseTable(scoped, edit), { active });
967
1055
  }
968
1056
  function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
969
1057
  if (!phaseNum) {
970
1058
  error('phase number required for roadmap update-plan-progress');
971
1059
  }
972
1060
  const roadmapPath = planningPaths(cwd).roadmap;
973
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
1061
+ const directoryContext = phaseDirectoryOptions(cwd);
1062
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
974
1063
  if (!phaseInfo) {
975
1064
  error(`Phase ${phaseNum} not found`);
976
1065
  }
@@ -1075,6 +1164,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1075
1164
  // refusal below declines with the analyzer's own `missing_phase_details`
1076
1165
  // vocabulary and leaves ROADMAP.md byte-identical.
1077
1166
  let missingPhaseDetails = false;
1167
+ let archiveRestoreFailed = false;
1168
+ let archiveAmbiguous = false;
1078
1169
  const warnings = [];
1079
1170
  withPlanningLock(cwd, () => {
1080
1171
  // #3957 (B9.4): captured BEFORE any transform runs, so the write/report
@@ -1082,9 +1173,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1082
1173
  // anything — not just that they ran. Every transform below still runs
1083
1174
  // unconditionally exactly as before; only the final write-and-report
1084
1175
  // step becomes conditional on `roadmapContent !== originalContent`.
1085
- const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1176
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1177
+ // #5553: every transform below runs on the text with archived `<details>`
1178
+ // blocks hidden, so none can reach an archive; restored before the write.
1179
+ const archived = roadmapParserModule.maskArchivedDetails(rawContent, cwd);
1180
+ const originalContent = archived.visible;
1086
1181
  let roadmapContent = originalContent;
1087
- const phasePattern = phaseMarkdownRegexSource(phaseNum);
1182
+ // #5186: a custom ID such as SETUP-2 is literal, not numeric phase 2.
1183
+ const phasePattern = phaseMarkdownRegexSource(phaseNum, directoryContext);
1088
1184
  // #4247: ONE local source for the ATX phase-heading anchor that every
1089
1185
  // section-scoped writer below (`planSectionPattern`,
1090
1186
  // `insertRowsPatternA|B`) starts with — extracted so the target-detection
@@ -1101,16 +1197,12 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1101
1197
  // phase-id-owner: deliberately unmigrated (#4984 revert) — see comment above.
1102
1198
  const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
1103
1199
  // #4247: target detection runs against the ORIGINAL content's active
1104
- // (post-</details>) region — the same milestone scoping every writer
1200
+ // (archive-masked) text — the same milestone scoping every writer
1105
1201
  // below applies — so the gate asks "does the file carry a writable phase
1106
1202
  // representation" rather than "did some regex fire mid-transform".
1107
- const gateDetailsClose = originalContent.lastIndexOf('</details>');
1108
- const gateActiveRegion = gateDetailsClose === -1
1109
- ? originalContent
1110
- : originalContent.slice(gateDetailsClose + '</details>'.length);
1111
1203
  // Heading target: the exact grammar `planSectionPattern` /
1112
1204
  // `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
1113
- const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion);
1205
+ const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(originalContent);
1114
1206
  // #5545: the phase-checklist bullet grammar, defined ONCE for the writer
1115
1207
  // (an unchecked bullet to tick) and the gate probe (`anyState`).
1116
1208
  // Line-anchored, with only an optional `**` before the label and horizontal
@@ -1121,16 +1213,18 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1121
1213
  // same narrowing #4965 made for `roadmap analyze`'s checklist reader.
1122
1214
  // The lookahead only PEEKS (its `\s` may see a newline but consumes none):
1123
1215
  // the label must be followed by `:`, whitespace or the end of the line, so
1124
- // `Phase 2.1` / `Phase 20` are not `Phase 2`. Capture group 1 is the opening
1125
- // `- [`, group 2 the rest of the line after the box.
1126
- const phaseChecklistBullet = (anyState) => new RegExp(`^([ \\t]*-[ \\t]*\\[)${anyState ? '[ x]' : ' '}(\\][ \\t]*(?:\\*\\*)?[ \\t]*Phase[ \\t]+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s]|$)[^\\n]*)`, 'im');
1216
+ // `Phase 2.1` / `Phase 20` are not `Phase 2`. Capture group 1 is everything through
1217
+ // the opening `[`, group 2 the rest of the line from the closing `]`. The grammar (marker,
1218
+ // box, bold token) is the phase-entry grammar's, shared with every reader
1219
+ // and writer through roadmap-parser's `phaseChecklistBullet` (#5764).
1220
+ const phaseChecklistBullet = (anyState) => phaseChecklistBulletFor(phasePattern, anyState);
1127
1221
  // Checklist target: when the phase is complete, its own checklist bullet
1128
1222
  // (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
1129
1223
  // checkbox stamp below updates it. Same grammar as that writer, widened
1130
1224
  // one notch to `[ x]` so an ALREADY-checked bullet still counts as a
1131
1225
  // found target: an idempotent re-run then takes the honest
1132
1226
  // "no changes were needed" decline instead of this refusal.
1133
- const checklistTargetFound = isComplete && phaseChecklistBullet(true).test(gateActiveRegion);
1227
+ const checklistTargetFound = isComplete && phaseChecklistBullet(true).test(originalContent);
1134
1228
  // Table-row target: set by the row-scoped cell updates below.
1135
1229
  let tableRowFound = false;
1136
1230
  // Progress table row: update Plans Complete/Status/Completed columns BY
@@ -1203,14 +1297,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1203
1297
  tableRowFound = true;
1204
1298
  }
1205
1299
  return text;
1206
- });
1300
+ }, archived.activeRange);
1207
1301
  // #4947: a word-prefixed Phase cell (`Phase 68`) is deliberately not a
1208
1302
  // writable table target (#4247 — the read side ignores it too), but when the
1209
1303
  // phase's heading section is still written the command reports
1210
1304
  // `updated: true` and the unwritten Status cell looked like a silent bug.
1211
1305
  // The row stays byte-identical; the output now says so.
1212
1306
  const wordPrefixedCellRe = new RegExp(`^Phase\\s+${phasePattern}\\.?(?:\\s|$)`, 'i');
1213
- const progressTable = tableRowFound ? null : (0, markdown_table_cjs_1.findTableWithColumns)(gateActiveRegion, ['Phase', 'Status']);
1307
+ const progressTable = tableRowFound ? null : (0, markdown_table_cjs_1.findTableWithColumns)(originalContent, ['Phase', 'Status']);
1214
1308
  if (progressTable && progressTable.rows.some((row) => wordPrefixedCellRe.test((row['Phase'] ?? '').trim()))) {
1215
1309
  warnings.push(`Progress table row for phase ${formatDiagnosticToken(String(phaseNum))} was not updated: its Phase cell is word-prefixed ("Phase N"), and only a bare phase number (N or N.) is recognized. Edit that row by hand or rewrite the cell as the bare number.`);
1216
1310
  }
@@ -1332,7 +1426,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1332
1426
  : `; ${stillPending.length} ${m[2]}plan${stillPending.length === 1 ? '' : 's'} pending (${stillPending.join(', ')})`;
1333
1427
  return tail.slice(0, m.index) + replacement + tail.slice(m.index + m[0].length);
1334
1428
  };
1335
- roadmapContent = replaceInCurrentMilestone(roadmapContent, planSectionPattern, (sectionText) => {
1429
+ roadmapContent = roadmapContent.replace(planSectionPattern, (sectionText) => {
1336
1430
  const parsed = (0, planning_document_cjs_1.parsePlanningDoc)(sectionText, 'ROADMAP.md');
1337
1431
  if (!parsed.ok) {
1338
1432
  // Unreadable section (e.g. an unterminated frontmatter fence) —
@@ -1412,7 +1506,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1412
1506
  });
1413
1507
  // If complete: check checkbox
1414
1508
  if (isComplete) {
1415
- roadmapContent = replaceInCurrentMilestone(roadmapContent, phaseChecklistBullet(false), `$1x$2 (completed ${today})`);
1509
+ roadmapContent = roadmapContent.replace(phaseChecklistBullet(false), `$1x$2 (completed ${today})`);
1416
1510
  }
1417
1511
  // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
1418
1512
  // #4741: tick only plans the phase's own COUNT still counts. `plans` is the
@@ -1429,19 +1523,98 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1429
1523
  .map((summaryFile) => ({ summaryFile, planId: summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '') }))
1430
1524
  .filter(({ summaryFile, planId }) => planId !== ''
1431
1525
  && coreUtils.summaryPairsWithAnyPlan(phaseInfo.plans, summaryFile));
1432
- for (const { planId } of tickableSummaries) {
1433
- roadmapContent = roadmapContent.replace(coreUtils.planRowPattern(planId), '$1x$2');
1434
- }
1435
- // Compute the active (post-</details>) region offset ONCE. Both the
1436
- // missing-plan DETECTION and the row INSERTION must use the same active
1437
- // region string so that a plan row that exists only in an archived <details>
1438
- // block is not counted as "already present" in the active milestone section.
1526
+ // #5483: a plan's row is ticked only in THIS phase's own scopes (its detail
1527
+ // section, then the rows under its checklist bullet, as `phase complete` does).
1528
+ // A scope that was located but holds no row ticks nothing, so a sibling phase's
1529
+ // same-named row is never touched; only a phase with neither scope falls back
1530
+ // to the first row outside every phase bullet's rows. Archived blocks are
1531
+ // already hidden by maskArchivedDetails (#5553), which owns which regions are editable.
1532
+ const editPhaseScopes = (text, edit, levelBounded = false) => {
1533
+ let out = withPhaseSection(text, phaseNum, edit, levelBounded);
1534
+ const bullet = phaseChecklistBullet(true).exec(out);
1535
+ if (bullet) {
1536
+ const { start, end } = checklistBulletExtent(out, bullet);
1537
+ out = out.slice(0, start) + edit(out.slice(start, end)) + out.slice(end);
1538
+ }
1539
+ return out;
1540
+ };
1541
+ // Read-only view of the scopes `editPhaseScopes` edits: the non-empty text of each.
1542
+ const readPhaseScopes = (text, levelBounded = false) => {
1543
+ const scopes = [];
1544
+ editPhaseScopes(text, (scope) => { if (scope.trim() !== '')
1545
+ scopes.push(scope); return scope; }, levelBounded);
1546
+ return scopes;
1547
+ };
1548
+ // Decided once on the pre-insertion text: insertion only adds rows under an existing section.
1549
+ const phaseScopes = readPhaseScopes(roadmapContent, true);
1550
+ const located = phaseScopes.length > 0;
1551
+ // Same-length copy of `text` with every phase's detail section and checklist-bullet rows blanked: what is
1552
+ // left is "unowned" text (e.g. rows under a non-phase heading inside the active milestone), which belongs to no sibling phase.
1553
+ const unownedText = (text) => {
1554
+ const spans = [];
1555
+ const heads = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
1556
+ heads.forEach((h, i) => {
1557
+ if (/^Phase\b/i.test(h.text.replace(/^\[[^\]]*\]\s*/, '')))
1558
+ spans.push([h.offset, heads[i + 1]?.offset ?? text.length]);
1559
+ });
1560
+ for (const b of text.matchAll(ANY_PHASE_ENTRY_LINE))
1561
+ spans.push([b.index, checklistBulletExtent(text, b).end]);
1562
+ let out = text;
1563
+ for (const [a, z] of spans)
1564
+ out = out.slice(0, a) + out.slice(a, z).replace(/[^\r\n]/g, ' ') + out.slice(z);
1565
+ return out;
1566
+ };
1567
+ const tickUnowned = (text, planId) => {
1568
+ const blank = unownedText(text);
1569
+ const ticked = coreUtils.tickAnchoredPlanRow(blank, planId);
1570
+ if (ticked === blank)
1571
+ return text;
1572
+ let out = '';
1573
+ for (let i = 0; i < text.length; i++)
1574
+ out += ticked[i] !== blank[i] ? ticked[i] : text[i];
1575
+ return out;
1576
+ };
1577
+ const fallbackTicked = new Set();
1578
+ const tickPlanRow = (text, planId) => {
1579
+ // levelBounded: a row under the phase's own `#### Plans` sub-heading ticks too.
1580
+ const out = editPhaseScopes(text, (scope) => coreUtils.tickAnchoredPlanRow(scope, planId), true);
1581
+ if (out !== text)
1582
+ return out;
1583
+ // A located scope without the row: a row no sibling phase owns (under a non-phase heading) is still this phase's.
1584
+ if (located)
1585
+ return tickUnowned(text, planId);
1586
+ if (fallbackTicked.has(planId))
1587
+ return out;
1588
+ // First row outside every phase bullet's rows: one nested under a sibling's bullet is that sibling's.
1589
+ let ticked = text;
1590
+ let from = 0;
1591
+ for (const b of text.matchAll(ANY_PHASE_ENTRY_LINE)) {
1592
+ const { start, end } = checklistBulletExtent(text, b);
1593
+ const seg = text.slice(from, start);
1594
+ const t = coreUtils.tickAnchoredPlanRow(seg, planId);
1595
+ if (t !== seg) {
1596
+ ticked = text.slice(0, from) + t + text.slice(start);
1597
+ break;
1598
+ }
1599
+ from = end;
1600
+ }
1601
+ if (ticked === text) {
1602
+ const tail = coreUtils.tickAnchoredPlanRow(text.slice(from), planId);
1603
+ if (tail !== text.slice(from))
1604
+ ticked = text.slice(0, from) + tail;
1605
+ }
1606
+ if (ticked !== text)
1607
+ fallbackTicked.add(planId);
1608
+ return ticked;
1609
+ };
1610
+ const tickPlanRows = (text) => tickableSummaries.reduce((acc, { planId }) => tickPlanRow(acc, planId), text);
1611
+ roadmapContent = tickPlanRows(roadmapContent);
1612
+ // Detection reads presentRows (the archive-masked text plus rows hidden in
1613
+ // collapsed blocks of the active range); insertion edits the masked text only.
1614
+ // A plan row that exists only in an archived <details> block is therefore
1615
+ // not counted as "already present" in the active milestone section.
1439
1616
  // (Finding 1 code-review: detection was previously running against the full
1440
1617
  // roadmapContent, causing archived rows to suppress active-section inserts.)
1441
- const lastDetailsClose = roadmapContent.lastIndexOf('</details>');
1442
- const activeRegion = lastDetailsClose === -1
1443
- ? roadmapContent
1444
- : roadmapContent.slice(lastDetailsClose + '</details>'.length);
1445
1618
  // Compute which plan files are MISSING a checkbox row in the ACTIVE region.
1446
1619
  // This handles three cases:
1447
1620
  // (a) Fresh template — no rows at all: all plans are missing.
@@ -1461,12 +1634,20 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1461
1634
  // up to the SAME boundary the tick loop uses (`planRowPattern`, #5542), so
1462
1635
  // `5-011` never satisfies `5-01` and a dotted sub-id (`5-01.5`) is a real
1463
1636
  // distinct plan whose row does not count as 5-01's presence.
1637
+ // Rows inside a collapsed block of the active range are hidden from edits but still present.
1638
+ // #5483: presence is judged in THIS phase's own scopes (plus rows hidden in collapsed active blocks), so a
1639
+ // sibling phase's same-named un-prefixed row never makes a plan look present; a phase with no scope reads the whole text.
1640
+ const presentRows = located
1641
+ ? `${readPhaseScopes(roadmapContent, true).join('\n')}\n${archived.hiddenInActive}`
1642
+ : `${roadmapContent}\n${archived.hiddenInActive}`;
1643
+ const unownedNow = located ? unownedText(roadmapContent) : '';
1644
+ const hasUnownedAny = (planFile, stem) => new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${(0, pattern_cjs_1.escapeRegex)(planFile)}`, 'i').test(unownedNow) || coreUtils.planRowPattern(stem, { anyState: true }).test(unownedNow);
1464
1645
  const missingPlans = phaseInfo.plans.filter((planFile) => {
1465
1646
  const planEscaped = (0, pattern_cjs_1.escapeRegex)(planFile);
1466
- if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion))
1647
+ if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(presentRows))
1467
1648
  return false;
1468
1649
  const stem = planFile.replace(/-PLAN\.md$/i, '');
1469
- return !coreUtils.planRowPattern(stem, { anyState: true }).test(activeRegion);
1650
+ return !coreUtils.planRowPattern(stem, { anyState: true }).test(presentRows) && !(located && hasUnownedAny(planFile, stem));
1470
1651
  });
1471
1652
  if (missingPlans.length > 0) {
1472
1653
  // Insert missing plan checklist rows (#1163). We prefer to anchor to the
@@ -1488,32 +1669,16 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1488
1669
  const sortedMissing = [...missingPlans].sort();
1489
1670
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
1490
1671
  const inserter = (match) => `${match}\n${newRows}`;
1491
- // Scope insertion to the active (post-</details>) milestone region to
1492
- // prevent duplicate phase headings in archived sections from receiving rows.
1493
- // replaceInCurrentMilestone only accepts a string replacement, so we
1494
- // perform the scoped replace manually here (same strategy as that helper).
1495
- // Note: lastDetailsClose was computed above (shared with detection).
1496
- const scopedReplace = (src, pat) => src.replace(pat, inserter);
1497
- let withRows;
1498
- if (lastDetailsClose === -1) {
1499
- // activeRegion === roadmapContent when there are no </details> blocks.
1500
- const regionA = scopedReplace(activeRegion, insertRowsPatternA);
1501
- withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
1502
- }
1503
- else {
1504
- const beforeDetails = roadmapContent.slice(0, lastDetailsClose + '</details>'.length);
1505
- const regionA = scopedReplace(activeRegion, insertRowsPatternA);
1506
- const afterWithRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
1507
- withRows = beforeDetails + afterWithRows;
1508
- }
1672
+ // Archived `<details>` blocks are already hidden (maskArchivedDetails), so
1673
+ // archived duplicate phase headings cannot receive rows.
1674
+ const regionA = roadmapContent.replace(insertRowsPatternA, inserter);
1675
+ const withRows = regionA !== roadmapContent ? regionA : roadmapContent.replace(insertRowsPatternB, inserter);
1509
1676
  if (withRows !== roadmapContent) {
1510
1677
  roadmapContent = withRows;
1511
1678
  // Mark any newly-inserted rows that already have summaries as complete
1512
1679
  // (#4741: same superseded-filtered tick list as the loop above — a
1513
1680
  // pre-existing superseded row must stay unchecked on this path too).
1514
- for (const { planId } of tickableSummaries) {
1515
- roadmapContent = roadmapContent.replace(coreUtils.planRowPattern(planId), '$1x$2');
1516
- }
1681
+ roadmapContent = tickPlanRows(roadmapContent);
1517
1682
  }
1518
1683
  }
1519
1684
  // #3957 (B9.4): write and report an update only when the transforms
@@ -1532,8 +1697,16 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1532
1697
  // left byte-identical, and the caller gets a typed
1533
1698
  // `missing_phase_details` decline instead of a false green.
1534
1699
  const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
1535
- if (phaseRepresentationFound && roadmapContent !== originalContent) {
1536
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, roadmapContent);
1700
+ // Unpaired <details> tags make the archive extents a guess: refuse rather than edit inconsistently.
1701
+ const finalContent = archived.ambiguous ? null : archived.restore(roadmapContent);
1702
+ if (archived.ambiguous) {
1703
+ archiveAmbiguous = true;
1704
+ }
1705
+ else if (finalContent === null) {
1706
+ archiveRestoreFailed = true;
1707
+ }
1708
+ else if (phaseRepresentationFound && finalContent !== rawContent) {
1709
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, finalContent);
1537
1710
  updated = true;
1538
1711
  }
1539
1712
  if (!phaseRepresentationFound) {
@@ -1551,6 +1724,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1551
1724
  if (updated) {
1552
1725
  output(warnings.length > 0 ? { updated: true, ...computed, warnings } : { updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
1553
1726
  }
1727
+ else if (archiveAmbiguous) {
1728
+ declineNoOp(raw, 'updated', 'archive_structure_ambiguous', 'roadmap update-plan-progress skipped — ROADMAP.md has an unpaired <details> tag that hides where an archived block ends. ROADMAP.md was left unchanged.', computed);
1729
+ }
1730
+ else if (archiveRestoreFailed) {
1731
+ // An edit dropped an archive placeholder (#5553): writing would lose that
1732
+ // archive, so refuse loudly instead of reporting "no changes were needed".
1733
+ declineNoOp(raw, 'updated', 'archive_placeholder_lost', 'roadmap update-plan-progress skipped — an edit would have dropped an archived ROADMAP block. ROADMAP.md was left unchanged.', computed);
1734
+ }
1554
1735
  else if (missingPhaseDetails) {
1555
1736
  // #4247: honest refusal — the reason names the real condition (the
1556
1737
  // analyzer's `missing_phase_details` vocabulary), never "already
@@ -1563,6 +1744,23 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1563
1744
  }
1564
1745
  }
1565
1746
  // ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
1747
+ /** #5321: tags a generated constraints heading so re-runs can tell it from a pre-#5321 one. */
1748
+ const GENERATED_MARK = ' <!-- gsd:generated -->';
1749
+ const BULLET_RE = /^\s*-\s+/;
1750
+ /**
1751
+ * #5321: first checklist-shaped row left after the matched Plans block, scanning the rest of the
1752
+ * Plans region — it ends at a heading or an unindented non-list line, so a later section's
1753
+ * checklist is never mistaken for a stranded plan row. Indented detail lines are skipped, not final.
1754
+ */
1755
+ function findStrandedRow(rest) {
1756
+ for (const l of rest) {
1757
+ if (/^[ \t]*[-*+][ \t]+\[[ \t]*[xX ]?[ \t]*\]/.test(l))
1758
+ return l;
1759
+ if (/^#{1,6}\s/.test(l) || /^\S/.test(l) && !/^(?:[-*+]\s|\*\*Wave\s+\d+\*\*)/i.test(l))
1760
+ return undefined;
1761
+ }
1762
+ return undefined;
1763
+ }
1566
1764
  /**
1567
1765
  * Annotate the ROADMAP.md plan list for a phase with wave dependency notes
1568
1766
  * and a cross-cutting constraints subsection derived from PLAN frontmatter.
@@ -1573,8 +1771,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1573
1771
  * Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
1574
1772
  * are surfaced in a "Cross-cutting constraints" subsection below the plan list.
1575
1773
  *
1576
- * The operation is idempotent: if wave headers already exist in the section
1577
- * the function returns without modifying the file.
1774
+ * Re-running regenerates the annotation from current PLAN frontmatter (replans
1775
+ * update waves/constraints, #5321); idempotent: an unchanged result writes nothing.
1578
1776
  */
1579
1777
  function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1580
1778
  if (!phaseNum) {
@@ -1585,7 +1783,8 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1585
1783
  declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap annotate-dependencies skipped — ROADMAP.md not found.');
1586
1784
  return;
1587
1785
  }
1588
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
1786
+ const directoryContext = phaseDirectoryOptions(cwd);
1787
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
1589
1788
  // #3957 (B9.1): distinguish "phase does not resolve at all" from "phase
1590
1789
  // resolves but has zero plans" — previously both collapsed into the same
1591
1790
  // 'no plans found for phase' reason, which is simply false for the first
@@ -1655,6 +1854,9 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1655
1854
  .map(v => v.text);
1656
1855
  // Patch ROADMAP.md
1657
1856
  let updated = false;
1857
+ let skipped;
1858
+ let staleLegacy = [];
1859
+ let droppedGenerated = [];
1658
1860
  withPlanningLock(cwd, () => {
1659
1861
  const content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1660
1862
  // #3413: preserve the file's own EOL style when the checklist block below
@@ -1665,7 +1867,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1665
1867
  // Find the phase section.
1666
1868
  // #3537: padding-tolerant fragment so the caller's resolved padded id
1667
1869
  // matches un-padded ROADMAP headings.
1668
- const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1870
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum, directoryContext);
1669
1871
  // #4906 Phase 5 (#4984): NOT migrated onto the heading-baseline selector —
1670
1872
  // this site was never one of the five ADR-4910 §8 call sites this phase
1671
1873
  // owns (init.cts/milestone.cts's buildPhaseHeadingScanRegex sites); a
@@ -1684,10 +1886,6 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1684
1886
  const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
1685
1887
  const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
1686
1888
  const phaseSection = content.slice(phaseStart, phaseEnd);
1687
- // Idempotency: skip if annotation markers already present
1688
- if (/\*\*Wave\s+\d+/i.test(phaseSection) ||
1689
- /\*\*Cross-cutting constraints:\*\*/i.test(phaseSection))
1690
- return;
1691
1889
  // Find the Plans: section within the phase section.
1692
1890
  // #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
1693
1891
  // `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
@@ -1707,15 +1905,32 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1707
1905
  // it accepts the first complete parse and stops, which rules out the #2128-class
1708
1906
  // ambiguous-boundary blowup despite the nested-quantifier shape. Non-global match on
1709
1907
  // already phase-sliced content, not the whole file.
1710
- // eslint-disable-next-line local/no-unbounded-quantifier -- outer `+` has no trailing constraint to force re-partitioning; measured linear to 10.9MB
1711
- const plansBlockMatch = phaseSection.match(/(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:\s*-\s*\[[ x]\][^\r\n]*\r?\n?)+)/i);
1908
+ // #5321: re-runs rebuild a prior annotation: group 2 swallows generated `**Wave N**` headers
1909
+ // only (user prose kept); group 3 the exact constraints header + bullets, never read as plans.
1910
+ // Block ends in one blank line (group 3 eats it) so later user bullets survive; legacy blocks
1911
+ // (no marker) keep bullets that are not current shared truths; the exact heading is reserved.
1912
+ // Blank lines inside the list are crossed only before a plan-id row, so a hand-written bullet after a blank line stays out.
1913
+ // Outer `+` has no trailing constraint to force re-partitioning; alternatives start with distinct literals; measured linear to 10.9MB.
1914
+ const plansBlockMatch = phaseSection.match(new RegExp(String.raw `(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:[ \t]*-\s*\[[ x]\][^\r\n]*\r?\n?|(?:[ \t]*\r?\n)+(?=[ \t]*-\s*\[[ x]\]\s*[\w.-]+?(?:-PLAN\.md|\.md|:|\s—))|\s*\*\*Wave\s+\d+\*\*(?: \*\(blocked on Wave \d+ completion\)\*)?[ \t]*(?:\r?\n|$)(?=\s*-\s*\[[ x]\]))+)(\s*\*\*Cross-cutting constraints:\*\*(${(0, pattern_cjs_1.escapeRegex)(GENERATED_MARK)})?[ \t]*(?:\r?\n|$)(?:[ \t]*-[ \t]+[^\r\n]*\r?\n?)*(?:[ \t]*\r?\n)?)?`, 'i'));
1712
1915
  if (!plansBlockMatch)
1713
1916
  return;
1714
1917
  const plansHeader = plansBlockMatch[1];
1715
1918
  const existingList = plansBlockMatch[2];
1716
- const listLines = (0, text_lines_cjs_1.splitLines)(existingList).filter(l => /^\s*-\s*\[/.test(l));
1919
+ const listLines = (0, text_lines_cjs_1.splitLines)(existingList).filter(l => /^\s*-\s*\[[ x]\]/i.test(l));
1717
1920
  if (listLines.length === 0)
1718
1921
  return;
1922
+ const oldBlock = plansBlockMatch[3] ? (0, text_lines_cjs_1.splitLines)(plansBlockMatch[3]) : [];
1923
+ // #5321: a checklist row group 2 rejects (`- [ ]`, `* [ ]`), or an empty generated wave
1924
+ // header, splits the list; rewriting only the prefix would orphan the rest — skip loudly.
1925
+ const stranded = plansBlockMatch[3] ? undefined : findStrandedRow((0, text_lines_cjs_1.splitLines)(phaseSection.slice(plansBlockMatch.index + plansBlockMatch[0].length)));
1926
+ const warn = (head, tail) => {
1927
+ process.stderr.write(`[gsd-tools] WARNING: roadmap annotate-dependencies ${head}${formatDiagnosticToken(String(phaseNum))}${tail}`);
1928
+ };
1929
+ if (stranded) {
1930
+ warn(`skipped — unrecognised checklist row ${formatDiagnosticToken(stranded.trim())} splits the Plans list of phase `, '.\n');
1931
+ skipped = 'unrecognised_checklist_row';
1932
+ return;
1933
+ }
1719
1934
  // #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
1720
1935
  // First-wins mirrors .find() semantics: if the same planId appears more than once
1721
1936
  // in planData, the earlier entry wins — identical to what .find() returned before.
@@ -1761,11 +1976,38 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1761
1976
  // Append cross-cutting constraints subsection if any found
1762
1977
  if (crossCuttingTruths.length > 0) {
1763
1978
  annotatedLines.push('');
1764
- annotatedLines.push('**Cross-cutting constraints:**');
1979
+ annotatedLines.push(`**Cross-cutting constraints:**${GENERATED_MARK}`);
1765
1980
  for (const t of crossCuttingTruths) {
1766
1981
  annotatedLines.push(`- ${t}`);
1767
1982
  }
1983
+ annotatedLines.push(''); // #5321: delimiter so user bullets after the block survive re-runs
1984
+ }
1985
+ // #5321: bullets of the old constraints block that equal ANY current plan truth were generated
1986
+ // from one (rebuilt above, or no longer shared): drop them. Any other bullet is a user bullet or
1987
+ // a reworded/removed constraint; both look alike. Without the generated marker (pre-#5321) keep
1988
+ // and report it; under the marker the block is machine-owned, so drop it but warn.
1989
+ if (plansBlockMatch[3]) {
1990
+ const unknown = oldBlock
1991
+ .filter(l => BULLET_RE.test(l) && !truthCounts.has(l.replace(BULLET_RE, '').trim().toLowerCase()));
1992
+ const texts = unknown.map(l => l.replace(BULLET_RE, '').trim());
1993
+ const names = texts.map(formatDiagnosticToken).join('; ');
1994
+ if (unknown.length > 0 && plansBlockMatch[4]) {
1995
+ droppedGenerated = texts;
1996
+ warn(`dropped ${unknown.length} bullet(s) under the generated `
1997
+ + `constraints heading that match no current plan truth in phase `, `: ${names} — put your own bullets after a blank line.\n`);
1998
+ }
1999
+ else if (unknown.length > 0) {
2000
+ if (crossCuttingTruths.length === 0)
2001
+ annotatedLines.push('');
2002
+ annotatedLines.push(...unknown);
2003
+ staleLegacy = texts;
2004
+ warn(`kept ${unknown.length} bullet(s) under a pre-#5321 `
2005
+ + `constraints block that match no current plan truth in phase `, `: ${names} — delete any that are stale.\n`);
2006
+ }
1768
2007
  }
2008
+ // The old block's trailing blank line separated it from user content below: keep it when the block is dropped.
2009
+ if (oldBlock.length > 2 && !oldBlock[oldBlock.length - 2].trim() && annotatedLines[annotatedLines.length - 1] !== '')
2010
+ annotatedLines.push('');
1769
2011
  const newListBlock = (0, text_lines_cjs_1.joinLines)(annotatedLines, eol) + eol;
1770
2012
  // #1103: when `(?:^|\r?\n)` consumed a leading terminator (mid-string
1771
2013
  // match), re-emit it verbatim so the line preceding the Plans: header is
@@ -1783,7 +2025,8 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1783
2025
  // never pattern-interpreted.
1784
2026
  const newPhaseSection = phaseSection.replace(plansBlockMatch[0], () => leadingNewline + plansHeader + newListBlock);
1785
2027
  const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
1786
- if (nextContent === content)
2028
+ // #5321: compare post-normalize (platformWriteSync collapses blank runs), not raw strings.
2029
+ if (!(0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, content, nextContent))
1787
2030
  return;
1788
2031
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, nextContent);
1789
2032
  updated = true;
@@ -1793,7 +2036,10 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
1793
2036
  phase: phaseNum,
1794
2037
  waves: waves.length,
1795
2038
  cross_cutting_constraints: crossCuttingTruths.length,
1796
- }, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
2039
+ ...(skipped && { skipped }),
2040
+ ...(staleLegacy.length > 0 && { stale_legacy_constraints: staleLegacy }),
2041
+ ...(droppedGenerated.length > 0 && { dropped_generated_constraints: droppedGenerated }),
2042
+ }, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : skipped ? `skipped (${skipped})` : 'skipped (already up to date or no plan list)');
1797
2043
  }
1798
2044
  module.exports = {
1799
2045
  cmdRoadmapGetPhase,
@@ -1803,4 +2049,10 @@ module.exports = {
1803
2049
  cmdRoadmapUpdatePlanProgress,
1804
2050
  cmdRoadmapAnnotateDependencies,
1805
2051
  buildPhaseHeadingRegex,
2052
+ editFromPhaseTable,
2053
+ findPhaseTableHeader,
2054
+ editProgressTableSlice,
2055
+ foldPhaseId,
2056
+ insideOpenDetails,
2057
+ checklistEntrySrc,
1806
2058
  };