@opengsd/gsd-core 1.19.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 (131) 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/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -42,25 +42,50 @@ 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, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = 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
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const phaseIdDisplayMod = require("./phase-id-display.cjs");
48
+ // #4304: reuse the Phase Id Display Module's milestone/phase
49
+ // numeric canonicalization instead of re-deriving it here — see
50
+ // bracketWriteContext/bracketPhaseId below.
51
+ const { milestoneToken, phaseToken } = phaseIdDisplayMod;
52
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
46
53
  const pattern_cjs_1 = require("./pattern.cjs");
47
54
  const gate_exit_cjs_1 = require("./gate-exit.cjs");
48
55
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
49
56
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
50
57
  const phaseLocatorMod = require("./phase-locator.cjs");
51
- const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
58
+ const { phaseDirectoryOptions, findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup, } = phaseLocatorMod;
52
59
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
53
60
  const planningScopeMod = require("./planning-scope.cjs");
54
61
  const { SCOPE } = planningScopeMod;
55
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
56
63
  const roadmapParserMod = require("./roadmap-parser.cjs");
57
- const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editOutsideShippedBlocks, findMilestoneScopeHeadingLines } = roadmapParserMod;
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,
68
+ // #4304: every recognized milestone heading in the document,
69
+ // not just the active one — see bracketProgressSectionOwnedByOtherMilestone.
70
+ listMilestoneHeadings, selectMilestoneHeading,
71
+ // #4304: the SAME bracket milestone-boundary grammar the
72
+ // window locator uses (bracketAwareMilestoneSection), reused so the
73
+ // marker set below recognizes a version-less bracket milestone heading
74
+ // too, not only a version-token one.
75
+ isBracketMilestoneBoundary,
76
+ // #4304: the SAME closed/shipped-heading predicate
77
+ // currentMilestoneRawRanges uses, reused so the pre-mutation window guard
78
+ // (bracketOwnedLineOutsideActiveWindow) can recognize an archived section
79
+ // instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
80
+ isClosedMilestoneHeading, isClosedMilestoneDetails, isRecognizedMilestoneHeading, isPhaseEntryHeading, activeScopeStart, } = roadmapParserMod;
58
81
  // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
59
82
  // (pure computation, no I/O — no cycle on this path) for the intent-first
60
83
  // progress counters the phase-complete transaction passes downstream.
61
84
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
62
85
  const phaseLifecycleMod = require("./phase-lifecycle.cjs");
63
- const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent, milestoneVersionOf, locateProgressTable, normalizeMilestoneToken } = phaseLifecycleMod;
86
+ const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
87
+ // #5761: the Progress table locator and milestone-identity helpers live in their one owner.
88
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
64
89
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
65
90
  const planningWorkspace = require("./planning-workspace.cjs");
66
91
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -73,12 +98,13 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
73
98
  const clock_cjs_1 = require("./clock.cjs");
74
99
  const state_transition_cjs_1 = require("./state-transition.cjs");
75
100
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
101
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
102
+ const security_cjs_1 = require("./security.cjs");
76
103
  // #5060: the Phase Status Module owns the ROADMAP Status-cell token vocabulary.
77
104
  const phase_status_cjs_1 = require("./phase-status.cjs");
78
- const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
79
105
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap.cjs is an export= CommonJS module
80
106
  const roadmapMod = require("./roadmap.cjs");
81
- const { buildPhaseHeadingRegex } = roadmapMod;
107
+ const { buildPhaseHeadingRegex, findPhaseTableHeader, editProgressTableSlice, foldPhaseId, insideOpenDetails, checklistEntrySrc } = roadmapMod;
82
108
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
83
109
  const uatPredicate = require("./uat-predicate.cjs");
84
110
  const { evaluateUatPassed } = uatPredicate;
@@ -102,6 +128,10 @@ const milestoneLockMod = require("./milestone-lock.cjs");
102
128
  const planDocumentMod = require("./plan-document.cjs");
103
129
  const { parsePlanDocument, planIdFromFile } = planDocumentMod;
104
130
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, assertVerificationReportsReadable, withStateLock, updatePerformanceMetricsSection, } = stateMod;
131
+ // #4304: matchPhaseDirs admits legacy directories under bracket mode for
132
+ // migration compatibility. Only a directory carrying the owner grammar's
133
+ // bracket prefix may enter bracket-only result parsing.
134
+ const BRACKET_DIRECTORY_PREFIX_RE = new RegExp(`^${BRACKET_DIR_PREFIX_SRC}`);
105
135
  // Any .md file with PLAN anywhere in the basename — diagnostic net
106
136
  const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
107
137
  const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
@@ -109,43 +139,6 @@ const looksLikePlanFile = (f) => /\.md$/i.test(f) &&
109
139
  /PLAN/i.test(f) &&
110
140
  !PLAN_OUTLINE_RE.test(f) &&
111
141
  !PLAN_PRE_BOUNCE_RE.test(f);
112
- /**
113
- * Scope an `updateTableCell` call to the `## Traceability` (or
114
- * `## Traceability Status`) heading's own section — up to the next H1/H2
115
- * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
116
- *
117
- * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
118
- * found in whatever text it is given. The shipped requirements template
119
- * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
120
- * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
121
- * an unscoped whole-file call targets the Out-of-Scope table instead, fails
122
- * with `{ok:false, reason:'unknown column: Status'}`, and the real
123
- * Traceability row is never flipped, while the checkbox surface still flips
124
- * and the command reports success (the #2140 silent-divergence class one
125
- * level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
126
- * `## Progress` writes to that heading's own slice for the same reason.
127
- *
128
- * Falls back to running `updateTableCell` against the whole `text` when no
129
- * `## Traceability` heading exists — matching the previous (unscoped)
130
- * behaviour for a REQUIREMENTS.md whose traceability table sits under some
131
- * other heading, or with no heading at all (never worse than before this fix).
132
- */
133
- function updateTraceabilityCell(text, match, column, newValue) {
134
- const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
135
- if (!headingMatch || headingMatch.index === undefined) {
136
- return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
137
- }
138
- const headingOffset = headingMatch.index;
139
- const before = text.slice(0, headingOffset);
140
- const fromHeading = text.slice(headingOffset);
141
- const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
142
- const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
143
- const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
144
- const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
145
- if (!result.ok)
146
- return result;
147
- return { ok: true, value: before + result.value + after };
148
- }
149
142
  /**
150
143
  * Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
151
144
  * "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
@@ -213,11 +206,14 @@ function cmdPhasesList(cwd, options, raw) {
213
206
  if (phase) {
214
207
  // LOOKUP (b): search the physical set, plus archived when asked.
215
208
  const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
216
- const normalized = normalizePhaseName(phase);
209
+ const directoryContext = phaseDirectoryOptions(cwd);
210
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
217
211
  // The pool is #3185's (physical set + archived); the matcher is this
218
212
  // PR's. `dirs` is deliberately not read here: on this base it is not
219
- // assigned until the branch below picks a match.
220
- const { matches } = matchPhaseDirs(lookupPool, normalized);
213
+ // assigned until the branch below picks a match. Under bracket, the
214
+ // active project+milestone identity wins before migration-window legacy
215
+ // names; differently-qualified historical dirs are never first-picked.
216
+ const { matches } = matchPhaseDirsForLookup(lookupPool, lookup, undefined, directoryContext);
221
217
  const match = matches[0];
222
218
  if (!match) {
223
219
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
@@ -231,7 +227,8 @@ function cmdPhasesList(cwd, options, raw) {
231
227
  const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
232
228
  phaseScope = enumerated.scope;
233
229
  dirs = [...enumerated.value, ...archivedLabels];
234
- dirs.sort((a, b) => comparePhaseNum(a, b));
230
+ const directoryContext = phaseDirectoryOptions(cwd);
231
+ dirs.sort((a, b) => comparePhaseNum(a, b, directoryContext));
235
232
  }
236
233
  if (type) {
237
234
  const files = [];
@@ -296,45 +293,70 @@ function cmdPhasesList(cwd, options, raw) {
296
293
  }
297
294
  function cmdPhaseNextDecimal(cwd, basePhase, raw) {
298
295
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
299
- const normalized = normalizePhaseName(basePhase);
296
+ const lookup = resolvePhaseDirectoryLookup(cwd, basePhase);
297
+ const normalized = lookup.normalized;
298
+ // #4304: base existence and decimal-child inventory must resolve through
299
+ // the same convention. The former was bracket-aware while
300
+ // the latter still called the legacy-only scanner, proposing an occupied
301
+ // bracket sub-phase. Legacy conventions retain their historical call and
302
+ // output spelling byte-for-byte.
303
+ const convention = resolvePhaseIdConvention(cwd) === 'bracket' ? 'bracket' : undefined;
304
+ const bracketContext = convention === 'bracket'
305
+ ? bracketWriteContext(cwd, loadConfig(cwd))
306
+ : null;
300
307
  try {
301
308
  let baseExists = false;
302
309
  const decimalSet = new Set();
310
+ const bracketScanMeta = { bracketSpellingFound: false };
311
+ const scanDecimals = (roadmapContent) => {
312
+ const found = bracketContext
313
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent ? extractCurrentMilestone(roadmapContent, cwd) : '', normalized, bracketContext, bracketScanMeta)
314
+ : scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized);
315
+ for (const n of found)
316
+ decimalSet.add(n);
317
+ };
303
318
  if (node_fs_1.default.existsSync(phasesDir)) {
304
319
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
305
320
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
306
- baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
321
+ baseExists = matchPhaseDirs(dirs, normalized, convention, lookup.bracketContext, phaseDirectoryOptions(cwd)).matches.length > 0;
307
322
  }
308
323
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
309
324
  if (node_fs_1.default.existsSync(roadmapPath)) {
310
325
  try {
311
326
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
312
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
313
- decimalSet.add(n);
314
- }
327
+ scanDecimals(roadmapContent);
315
328
  }
316
329
  catch {
317
330
  // ROADMAP.md read failure is non-fatal — fall back to the directory-only
318
331
  // scan (empty rawContent) so on-disk decimal directories are still counted.
319
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
320
- decimalSet.add(n);
321
- }
332
+ scanDecimals('');
322
333
  }
323
334
  }
324
335
  else {
325
- for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
326
- decimalSet.add(n);
327
- }
336
+ scanDecimals('');
328
337
  }
338
+ // add-backlog still asks for parent 999 and writes legacy CK-999.x
339
+ // directories. Preserve that upstream output only when the sentinel
340
+ // parent is legacy-only; a real bracket-spelled parent keeps canonical
341
+ // two-digit sub-tokens like every other bracket parent.
342
+ const renderCanonicalBracket = bracketContext !== null
343
+ && !(Number(normalized) === 999 && !bracketScanMeta.bracketSpellingFound);
329
344
  const existingDecimals = Array.from(decimalSet)
330
345
  .sort((a, b) => a - b)
331
- .map((n) => `${normalized}.${n}`);
346
+ .map((n) => renderCanonicalBracket
347
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, n))
348
+ : `${normalized}.${n}`);
332
349
  let nextDecimal;
333
350
  if (decimalSet.size === 0) {
334
- nextDecimal = `${normalized}.1`;
351
+ nextDecimal = renderCanonicalBracket
352
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, 1))
353
+ : `${normalized}.1`;
335
354
  }
336
355
  else {
337
- nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
356
+ const next = maxPhaseNum(decimalSet) + 1;
357
+ nextDecimal = renderCanonicalBracket
358
+ ? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, next))
359
+ : `${normalized}.${next}`;
338
360
  }
339
361
  output({
340
362
  found: baseExists,
@@ -355,6 +377,10 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
355
377
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
356
378
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
357
379
  const fullContent = stripShippedMilestones(rawContent);
380
+ const convention = resolvePhaseIdConvention(cwd);
381
+ // LABEL_ONLY preserves the legacy `Phase N:` reader while adding the
382
+ // opted-in bracket heading alternative for both the target and its boundary.
383
+ const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
358
384
  const escapedPhase = phaseMarkdownRegexSource(phaseNum);
359
385
  // #5007 (Phase 6 / ADR-4910 §8): buildPhaseHeadingRegex (src/roadmap.cts) is
360
386
  // anchored `^...$` with no 'm' flag — it is designed to test ONE
@@ -363,7 +389,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
363
389
  // through tokenizeHeadings first is therefore required here, not a pure
364
390
  // regex-literal swap (the design doc's "direct swap" plan does not hold
365
391
  // once the anchor is accounted for).
366
- const phaseHeader = buildPhaseHeadingRegex(escapedPhase);
392
+ const phaseHeader = buildPhaseHeadingRegex(escapedPhase, convention);
367
393
  for (const content of [milestoneContent, fullContent]) {
368
394
  const heading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).find((h) => phaseHeader.test(h.text));
369
395
  if (!heading)
@@ -372,7 +398,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
372
398
  const headerMatchLength = (headingLineEnd === -1 ? content.length : headingLineEnd) - heading.offset;
373
399
  const sectionStart = heading.offset;
374
400
  const rest = content.slice(sectionStart);
375
- const nextHeader = rest.slice(headerMatchLength).match(/\n#{2,4}\s+Phase\s+\S/i);
401
+ const nextHeader = rest.slice(headerMatchLength).match(new RegExp(`\\n#{2,4}\\s+${headingIntro}\\S`, 'i'));
376
402
  const sectionEnd = nextHeader
377
403
  ? sectionStart + headerMatchLength + nextHeader.index
378
404
  : content.length;
@@ -470,7 +496,12 @@ function cmdFindPhase(cwd, phase, raw) {
470
496
  error('phase identifier required');
471
497
  }
472
498
  const planBase = planningDir(cwd);
473
- const normalized = normalizePhaseName(phase);
499
+ const directoryContext = phaseDirectoryOptions(cwd);
500
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
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);
474
505
  const notFound = {
475
506
  found: false,
476
507
  directory: null,
@@ -506,19 +537,21 @@ function cmdFindPhase(cwd, phase, raw) {
506
537
  }
507
538
  notFound.searched_directories = searchDirs.map((searchDir) => toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir))));
508
539
  for (const searchDir of searchDirs) {
540
+ const searchContext = phaseLocatorMod.phaseDirectoryContextForRoot(directoryContext, searchDir);
541
+ const custom = phaseIdMod.customPhaseQuery(phase, searchContext);
509
542
  try {
510
543
  const entries = node_fs_1.default.readdirSync(searchDir, { withFileTypes: true });
511
544
  const dirs = entries
512
545
  .filter((e) => e.isDirectory())
513
546
  .map((e) => e.name)
514
- .sort((a, b) => comparePhaseNum(a, b));
547
+ .sort((a, b) => comparePhaseNum(a, b, searchContext));
515
548
  // #2237: fail loud when multiple directories match the same bare phase
516
549
  // number — prevents cross-project file writes when unrelated projects
517
550
  // share a .planning/phases/ tree.
518
551
  // #2528: selection delegates to the canonical two-pass matcher (exact
519
552
  // token match, then the bare-integer leading-digit-run fallback) shared
520
553
  // with the locator and the phase-plan-index scan.
521
- const { matches } = matchPhaseDirs(dirs, normalized);
554
+ const { matches, usedBareFallback } = matchPhaseDirsForLookup(dirs, lookup, searchDir === flatPhasesDir ? undefined : null, searchContext);
522
555
  if (matches.length === 0)
523
556
  continue;
524
557
  if (matches.length > 1) {
@@ -530,9 +563,21 @@ function cmdFindPhase(cwd, phase, raw) {
530
563
  return;
531
564
  }
532
565
  const match = matches[0];
533
- const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
534
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
535
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
566
+ const isBracketDirectory = convention === 'bracket'
567
+ && BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
568
+ const dirMatch = isBracketDirectory
569
+ ? null
570
+ : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i')) || match.match(new RegExp(`^(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i'));
571
+ const customParts = custom ? customPhaseDirectoryParts(match, custom) : null;
572
+ const phaseNumber = isBracketDirectory
573
+ ? phaseNumberForMatch(match, usedBareFallback, convention)
574
+ : customParts?.id || (dirMatch ? dirMatch[1] : normalized);
575
+ let phaseName = customParts?.slug || (dirMatch && dirMatch[2] ? dirMatch[2] : null);
576
+ if (isBracketDirectory) {
577
+ const id = parsePhaseId(match);
578
+ const idToken = `${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
579
+ phaseName = match.slice(`${id.project}.${id.milestone}-${idToken}`.length).replace(/^-/, '') || null;
580
+ }
536
581
  const phaseDir = node_path_1.default.join(searchDir, match);
537
582
  const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
538
583
  // #3183: canonical, live (superseded-excluded) plan/summary sets
@@ -738,7 +783,9 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
738
783
  error('phase required for phase-plan-index');
739
784
  }
740
785
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
741
- const normalized = normalizePhaseName(phase);
786
+ const directoryContext = phaseDirectoryOptions(cwd);
787
+ const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
788
+ const { normalized } = lookup;
742
789
  let phaseDir = null;
743
790
  let phaseDirName = null;
744
791
  let ambiguousMatches = null;
@@ -747,12 +794,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
747
794
  const dirs = entries
748
795
  .filter((e) => e.isDirectory())
749
796
  .map((e) => e.name)
750
- .sort((a, b) => comparePhaseNum(a, b));
797
+ .sort((a, b) => comparePhaseNum(a, b, directoryContext));
751
798
  // #2528: selection delegates to the canonical two-pass matcher shared with
752
799
  // the locator and the find-phase scan (this site previously first-matched
753
800
  // with `.find()` and had no multi-match guard — the #2237 fail-loud rule
754
801
  // now applies here too, so the three resolution paths cannot disagree).
755
- const { matches } = matchPhaseDirs(dirs, normalized);
802
+ const { matches } = matchPhaseDirsForLookup(dirs, lookup, undefined, directoryContext);
756
803
  if (matches.length > 1) {
757
804
  ambiguousMatches = matches;
758
805
  }
@@ -1073,37 +1120,58 @@ function describeGoalShapedTitle(description) {
1073
1120
  * and sibling-worktree phases so a new number cannot collide, but a `Depends on:` line has to point
1074
1121
  * at something a reader of THIS roadmap can find (#5113).
1075
1122
  */
1076
- function roadmapDeclaredPhaseNums(rawContent) {
1077
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1078
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1123
+ function roadmapDeclaredPhaseNums(rawContent, withBullets = true) {
1079
1124
  const nums = new Set();
1080
- let m;
1081
- while ((m = headerPattern.exec(rawContent)) !== null) {
1082
- const num = parseInt(m[phaseNumGroup], 10);
1083
- if (!isSentinelPhaseId(num))
1084
- nums.add(num);
1085
- }
1086
- while ((m = bulletPattern.exec(rawContent)) !== null) {
1087
- const num = parseInt(m[1], 10);
1088
- if (!isSentinelPhaseId(num))
1125
+ for (const { regex, numGroup } of buildPhaseDeclarationScans(null, withBullets)) {
1126
+ let m;
1127
+ while ((m = regex.exec(rawContent)) !== null) {
1128
+ const num = parseInt(m[numGroup], 10);
1129
+ if (!Number.isSafeInteger(num))
1130
+ continue; // a 24-digit run parses to 1e24; max+1 of it is not a phase number
1131
+ if (isSentinelPhaseId(num))
1132
+ continue;
1089
1133
  nums.add(num);
1134
+ }
1090
1135
  }
1091
1136
  return [...nums];
1092
1137
  }
1138
+ /** The phase number an on-disk `[PROJECT-]NN-slug` directory holds, or null for a sentinel or a non-phase name. */
1139
+ function dirPhaseNum(entry) {
1140
+ const match = entry.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+)-/);
1141
+ if (!match)
1142
+ return null;
1143
+ const num = parseInt(match[1], 10);
1144
+ return !Number.isSafeInteger(num) || isSentinelPhaseId(num) ? null : num;
1145
+ }
1146
+ /** Add the phase number of every phase directory under `phasesDir` to `used` (a missing dir adds nothing). */
1147
+ function collectPhaseDirNums(phasesDir, used, bracketContext) {
1148
+ if (!node_fs_1.default.existsSync(phasesDir))
1149
+ return;
1150
+ for (const entry of node_fs_1.default.readdirSync(phasesDir)) {
1151
+ // A bracket writer reserves the directories its reader resolves (#4304), not the legacy reading.
1152
+ const num = bracketContext ? readerResolvableBracketDirectoryPhaseNumber(entry, bracketContext) : dirPhaseNum(entry);
1153
+ if (num !== null)
1154
+ used.add(num);
1155
+ }
1156
+ }
1093
1157
  /**
1094
1158
  * The `**Depends on:**` line for a freshly added numeric phase: the nearest lower phase the roadmap
1095
1159
  * declares, or `Nothing` when it declares none. It was `newId - 1` regardless, which named a phase
1096
1160
  * that exists only on a sibling branch, as an orphan directory, or not at all when numbering has a
1097
1161
  * gap (#5113). Custom-named phases carry no dependency line.
1098
1162
  */
1099
- function dependsOnLine(newPhaseId, declared) {
1163
+ function dependsOnLine(newPhaseId, declared, renderDependency = (phase) => `Phase ${phase}`) {
1100
1164
  if (typeof newPhaseId !== 'number')
1101
1165
  return '';
1102
1166
  let nearest = null;
1103
1167
  for (const n of declared)
1104
1168
  if (n < newPhaseId && (nearest === null || n > nearest))
1105
1169
  nearest = n;
1106
- return `\n**Depends on:** ${nearest === null ? 'Nothing' : `Phase ${nearest}`}`;
1170
+ return `\n**Depends on:** ${nearest === null ? 'Nothing' : renderDependency(nearest)}`;
1171
+ }
1172
+ /** `dependsOnLine` over the roadmap's declared phases: the whole roadmap, as it was before #5528. */
1173
+ function dependsOnFor(newPhaseId, rawContent) {
1174
+ return dependsOnLine(newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1107
1175
  }
1108
1176
  /**
1109
1177
  * Splice a phase entry into `rawContent` at `at`, changing nothing else: the blank lines around the
@@ -1123,6 +1191,74 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
1123
1191
  const tail = after === '' || /^\r?\n/.test(after) ? '' : eol;
1124
1192
  return before + lead + body + tail + after;
1125
1193
  }
1194
+ /** Segment-wise numeric order of two folded phase ids; a non-numeric segment never orders after. */
1195
+ function comparePhaseIds(a, b) {
1196
+ const x = a.split('.').map(Number);
1197
+ const y = b.split('.').map(Number);
1198
+ for (let n = 0; n < Math.max(x.length, y.length); n++) {
1199
+ const d = (x[n] ?? -1) - (y[n] ?? -1);
1200
+ if (Number.isNaN(d))
1201
+ return -1;
1202
+ if (d)
1203
+ return d;
1204
+ }
1205
+ return 0;
1206
+ }
1207
+ const spliceAfter = (raw, at, eol, line) => `${raw.slice(0, at)}${eol}${line}${raw.slice(at)}`;
1208
+ /**
1209
+ * #5221: give a tool-created phase a ROADMAP record, so ship's fail-closed gate can read it.
1210
+ * Writes `- [ ] **Phase N: desc**` after the active milestone's checklist (for an insert: after
1211
+ * phase `after`'s own entries, else before the first later entry); with no checklist, a `N. desc | Not started` progress-table row;
1212
+ * with neither, nothing. Entries inside an open <details> (shipped blocks) are not the checklist.
1213
+ */
1214
+ function recordNewPhase(raw, cwd, id, description, after, label) {
1215
+ const start = activeScopeStart(raw, cwd);
1216
+ // End at the active milestone's section so a later planned milestone's checklist is never the target.
1217
+ const end = currentMilestoneRawRanges(raw, cwd)?.primary.end;
1218
+ const scope = raw.slice(start, end !== undefined && end > start ? end : undefined);
1219
+ let custom = false;
1220
+ try {
1221
+ custom = loadConfig(cwd).phase_naming === 'custom';
1222
+ }
1223
+ catch { /* unreadable config: numeric ids only */ }
1224
+ const entrySrc = checklistEntrySrc(resolvePhaseIdConvention(cwd), false, custom);
1225
+ const entryRe = new RegExp(`^[ \\t]*${entrySrc}[^\\r\\n]*`, 'gim');
1226
+ const bullets = [...scope.matchAll(entryRe)].filter((m) => !insideOpenDetails(scope, m.index));
1227
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
1228
+ // A bracket writer records its entry in the bracket spelling it used for the heading (`[CK.02] 02`).
1229
+ const entry = `- [ ] **${label ?? `Phase ${id}`}: ${description}**`;
1230
+ // An id past CUSTOM_PHASE_ID_SRC's cap is written but not readable by analyze: report that, not 'checklist'.
1231
+ const kind = new RegExp(`^${entrySrc}`).test(entry) ? 'checklist' : 'none';
1232
+ if (after !== undefined) {
1233
+ const anchor = foldPhaseId(after);
1234
+ const own = bullets.filter((m) => foldPhaseId(m[1]) === anchor || foldPhaseId(m[1]).startsWith(`${anchor}.`)).at(-1);
1235
+ // Anchor phase has no entry: go before the first numerically later entry, so the checklist stays ordered.
1236
+ const later = own ? undefined : bullets.find((m) => comparePhaseIds(foldPhaseId(m[1]), anchor) > 0);
1237
+ if (later) {
1238
+ const at = start + later.index;
1239
+ return { content: `${raw.slice(0, at)}${entry}${eol}${raw.slice(at)}`, entry: kind };
1240
+ }
1241
+ if (own)
1242
+ return { content: spliceAfter(raw, start + own.index + own[0].length, eol, entry), entry: kind };
1243
+ }
1244
+ const last = bullets.at(-1);
1245
+ if (last)
1246
+ return { content: spliceAfter(raw, start + last.index + last[0].length, eol, entry), entry: kind };
1247
+ let wrote = false;
1248
+ const archived = roadmapParserMod.maskArchivedDetails(raw, cwd);
1249
+ const edited = editProgressTableSlice(archived.visible, (scoped) => {
1250
+ if (findPhaseTableHeader(scoped) === -1)
1251
+ return scoped;
1252
+ const cell = { Phase: `${id}. ${description}`, 'Plans Complete': '0/0', Status: 'Not started' };
1253
+ const r = (0, markdown_table_cjs_1.insertTableRow)(scoped, (col) => cell[col]);
1254
+ if (!r.ok)
1255
+ return scoped;
1256
+ wrote = true;
1257
+ return r.value;
1258
+ }, archived.activeRange);
1259
+ const content = wrote && !archived.ambiguous ? archived.restore(edited) : null;
1260
+ return content !== null ? { content, entry: 'progress_table' } : { content: raw, entry: 'none' };
1261
+ }
1126
1262
  /**
1127
1263
  * #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
1128
1264
  * entry should be inserted — at the end of the active phase list, scoped to the
@@ -1132,9 +1268,17 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
1132
1268
  * STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
1133
1269
  * legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
1134
1270
  * their existing behavior.
1271
+ *
1272
+ * #4304: `phaseIdConvention` is threaded through to
1273
+ * `currentMilestoneRawRanges` so a version-less bracket milestone heading
1274
+ * (`## [CK.02] Current`) is still offset-scoped — omitting it here silently
1275
+ * degraded to the legacy-only search, which finds nothing for that heading
1276
+ * shape and falls back to whole-document insertion, landing `phase add` /
1277
+ * `add-batch` outside the active milestone. Optional and additive: every
1278
+ * pre-existing non-bracket caller passes nothing and compiles byte-identically.
1135
1279
  */
1136
- function phaseEntryInsertOffset(rawContent, cwd) {
1137
- const ranges = currentMilestoneRawRanges(rawContent, cwd);
1280
+ function phaseEntryInsertOffset(rawContent, cwd, phaseIdConvention) {
1281
+ const ranges = currentMilestoneRawRanges(rawContent, cwd, phaseIdConvention);
1138
1282
  if (!ranges) {
1139
1283
  const legacy = rawContent.lastIndexOf('\n---');
1140
1284
  return legacy > 0 ? legacy : rawContent.length;
@@ -1186,14 +1330,142 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1186
1330
  `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
1187
1331
  markerList + `.`);
1188
1332
  }
1333
+ /**
1334
+ * Resolve the write identity shared by add/insert/remove. Convention selection
1335
+ * happens at the caller and is the sole branch gate; project/milestone checks
1336
+ * here validate the identity after that branch has already been selected.
1337
+ */
1338
+ function bracketWriteContext(cwd, config) {
1339
+ const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
1340
+ if (!project) {
1341
+ error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
1342
+ }
1343
+ const milestoneInfo = getMilestoneInfo(cwd);
1344
+ const version = milestoneInfo.value?.version ?? '';
1345
+ const milestone = milestoneToken(version);
1346
+ if (milestone === null) {
1347
+ error('phase_id_convention is "bracket" but the active milestone cannot be resolved');
1348
+ }
1349
+ return { project, milestone: milestone };
1350
+ }
1351
+ function bracketPhaseId(context, phase, subphase) {
1352
+ const phaseTok = phaseToken(phase);
1353
+ if (phaseTok === null) {
1354
+ error(`phase ${String(phase)} cannot be rendered by the bracket convention`);
1355
+ }
1356
+ const id = {
1357
+ project: context.project,
1358
+ milestone: context.milestone,
1359
+ phase: phaseTok,
1360
+ };
1361
+ if (subphase !== undefined) {
1362
+ const subphaseTok = phaseToken(subphase);
1363
+ if (subphaseTok === null) {
1364
+ error(`phase ${String(phase)} subphase ${JSON.stringify(subphase)} cannot be rendered by the bracket convention`);
1365
+ }
1366
+ id.subphase = subphaseTok;
1367
+ }
1368
+ return id;
1369
+ }
1370
+ /** The owner-rendered bare token used by plan/summary artifact filenames. */
1371
+ function bracketArtifactToken(id) {
1372
+ return renderPhaseId(id).split('] ')[1];
1373
+ }
1374
+ /** The directory-name prefix `toDir` emits for this identity, ahead of the slug. */
1375
+ function bracketDirPrefix(id) {
1376
+ const probeSlug = 'phase-slug-probe';
1377
+ return toDir(id, probeSlug).slice(0, -probeSlug.length);
1378
+ }
1379
+ function bracketDirSlug(dirName, id) {
1380
+ const prefix = bracketDirPrefix(id);
1381
+ return dirName.startsWith(prefix) ? dirName.slice(prefix.length) : null;
1382
+ }
1383
+ function bracketIdsInContext(phasesDir, context) {
1384
+ const found = [];
1385
+ for (const dir of readSubdirectories(phasesDir, true)) {
1386
+ try {
1387
+ const id = parsePhaseId(dir);
1388
+ if (id.project !== context.project || id.milestone !== context.milestone)
1389
+ continue;
1390
+ const slug = bracketDirSlug(dir, id);
1391
+ if (slug)
1392
+ found.push({ dir, id, slug });
1393
+ }
1394
+ catch {
1395
+ // A bracket writer shares the directory with migration-window legacy
1396
+ // names. Names outside the canonical bracket grammar do not allocate an
1397
+ // identity in this branch.
1398
+ }
1399
+ }
1400
+ return found;
1401
+ }
1402
+ /**
1403
+ * Return the top-level phase number when the active bracket reader resolves
1404
+ * this one directory. The reader prefers an exact project+milestone bracket
1405
+ * identity, falls back to migration-window legacy spellings, and excludes a
1406
+ * qualified directory owned by another milestone. Allocation must reserve
1407
+ * that same universe or a newly emitted bracket directory can shadow work the
1408
+ * reader resolved immediately before the write.
1409
+ */
1410
+ function readerResolvableBracketDirectoryPhaseNumber(dir, context) {
1411
+ const phaseKey = phaseKeyFromDir(dir, 'bracket');
1412
+ if (!/^\d+$/.test(phaseKey))
1413
+ return null;
1414
+ const phase = Number(phaseKey);
1415
+ if (!Number.isSafeInteger(phase) || isSentinelPhaseId(phase))
1416
+ return null;
1417
+ const { matches } = matchPhaseDirs([dir], phaseKey, 'bracket', context);
1418
+ return matches.includes(dir) ? phase : null;
1419
+ }
1420
+ function addBracketRoadmapPhaseNumbers(content, used, context) {
1421
+ for (const token of scanMilestonePhaseIds(content, 'bracket')) {
1422
+ const leading = String(token).split('.')[0];
1423
+ if (/^\d+$/.test(leading))
1424
+ used.add(Number(leading));
1425
+ }
1426
+ // #5528: a phase declared only as a checklist bullet (`- [ ] **[GSD.02] 05: x**`, with or without a
1427
+ // checkbox, letter-suffixed or word-first) is invisible to the heading scan above but still owns its
1428
+ // number. The bracket declaration scans see it; an id of another milestone only over-counts.
1429
+ if (!context)
1430
+ return;
1431
+ const active = `${context.project}.${context.milestone}`.toUpperCase();
1432
+ for (const { regex, idGroup, numGroup } of buildPhaseDeclarationScans('bracket')) {
1433
+ if (idGroup === null)
1434
+ continue;
1435
+ let hit;
1436
+ while ((hit = regex.exec(content)) !== null) {
1437
+ const num = parseInt(hit[numGroup], 10);
1438
+ if (!Number.isSafeInteger(num) || isSentinelPhaseId(num))
1439
+ continue;
1440
+ // A legacy `- [x] Phase 5: x` bullet (migration window) carries no bracket id: it still owns its number.
1441
+ if (!hit[idGroup] || hit[idGroup].toUpperCase() === active)
1442
+ used.add(num);
1443
+ }
1444
+ }
1445
+ }
1446
+ function collectBracketPhaseNumbers(roadmapContent, phasesDir, context) {
1447
+ const used = new Set();
1448
+ addBracketRoadmapPhaseNumbers(roadmapContent, used, context);
1449
+ for (const dir of readSubdirectories(phasesDir, true)) {
1450
+ const phase = readerResolvableBracketDirectoryPhaseNumber(dir, context);
1451
+ if (phase !== null)
1452
+ used.add(phase);
1453
+ }
1454
+ used.delete(0);
1455
+ used.delete(999);
1456
+ return used;
1457
+ }
1189
1458
  /**
1190
1459
  * #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
1191
1460
  * worktree carries its own `.planning/` on its own branch, so a phase minted
1192
1461
  * there is invisible to the cwd-scoped sources (headers, bullets, on-disk
1193
- * dirs). Scan each sibling's phase-directory names (cheap — dir names alone
1194
- * caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
1195
- * before any directory does; milestone-scoping is wrong here because a number
1196
- * used under any milestone on another branch is still taken).
1462
+ * dirs). Legacy identities scan each sibling's phase-directory names and whole
1463
+ * ROADMAP because their phase number has no milestone qualifier. Bracket ids
1464
+ * reserve the same reader-resolvable union used locally: exact identities for
1465
+ * the active project+milestone, migration-window legacy directory spellings,
1466
+ * and accepted ROADMAP spellings when the sibling is on that same bracket
1467
+ * identity. Each writer creates its directory in the same planning lock as its
1468
+ * ROADMAP entry.
1197
1469
  *
1198
1470
  * #4225 — the horizon must track the ALLOCATION scope. When the allocation is
1199
1471
  * workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
@@ -1211,14 +1483,15 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
1211
1483
  * allocation then behaves exactly as it did before this horizon existed.
1212
1484
  * A sibling that simply lacks the active workstream's directory is the same
1213
1485
  * fail-open case: it contributes nothing. Sentinels reuse the canonical
1214
- * `isSentinelPhaseId`; the dir pattern is the same one the on-disk scan uses,
1215
- * so decimal sub-phases (`411.1-foo`) are correctly not integers.
1486
+ * `isSentinelPhaseId` (`isSentinelPhaseDir` for bracket dirs); the dir pattern is the same one the
1487
+ * on-disk scan uses, so decimal sub-phases (`411.1-foo`, `GSD.02-05.1-foo`) are correctly not integers.
1216
1488
  */
1217
- function collectSiblingWorktreePhaseNums(cwd, used) {
1489
+ function collectSiblingWorktreePhaseNums(cwd, used, bracketContext) {
1218
1490
  let porcelain;
1219
1491
  try {
1220
1492
  porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
1221
1493
  cwd,
1494
+ env: (0, shell_command_projection_cjs_1.scrubGitLocationEnv)(process.env),
1222
1495
  encoding: 'utf-8',
1223
1496
  // Same subprocess band as the other git call sites (smart-entry, check-command-router):
1224
1497
  // inside the 5-30s git window, hidden console window on Windows, bounded buffer.
@@ -1237,16 +1510,6 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1237
1510
  // try/catch below still keeps any resolution failure fail-open.
1238
1511
  const ws = process.env['GSD_WORKSTREAM'] ?? null;
1239
1512
  const siblingPlanningDir = (wt) => planningDir(wt, ws);
1240
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1241
- // Same header shape the allocators scan locally (#1729 tag tolerance).
1242
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1243
- // (src/phase-id.cjs), the shared "which phases exist in this content" scan
1244
- // owner. LABEL_ONLY baseline with no convention argument reproduces the
1245
- // prior literal `Phase\s+` (no bracket tolerance) byte-for-byte — this is a
1246
- // counter, matching the LABEL_ONLY docstring guidance exactly, so it is
1247
- // deliberately NOT widened onto the bracket grammar the way the real-heading
1248
- // readers (getRoadmapModeForPhase, phaseDisplayNameFromRoadmap) are.
1249
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1250
1513
  for (const line of porcelain.split('\n')) {
1251
1514
  if (!line.startsWith('worktree '))
1252
1515
  continue;
@@ -1254,124 +1517,258 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1254
1517
  if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
1255
1518
  continue;
1256
1519
  try {
1257
- for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(siblingPlanningDir(wt), 'phases'))) {
1258
- const match = entry.match(dirNumPattern);
1259
- if (!match)
1260
- continue;
1261
- const num = parseInt(match[1], 10);
1262
- if (!isSentinelPhaseId(num))
1263
- used.add(num);
1264
- }
1520
+ collectPhaseDirNums(node_path_1.default.join(siblingPlanningDir(wt), 'phases'), used, bracketContext);
1265
1521
  }
1266
1522
  catch {
1267
1523
  /* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
1268
1524
  }
1269
1525
  try {
1270
1526
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
1271
- let m;
1272
- headerPattern.lastIndex = 0;
1273
- while ((m = headerPattern.exec(content)) !== null) {
1274
- const num = parseInt(m[phaseNumGroup], 10);
1275
- if (!isSentinelPhaseId(num))
1276
- used.add(num);
1527
+ if (bracketContext) {
1528
+ // Resolved without `bracketWriteContext`, whose `error()` writes to
1529
+ // stderr before throwing: a sibling with no project code or no
1530
+ // resolvable milestone simply contributes nothing.
1531
+ const siblingProject = loadConfig(wt)['project_code'];
1532
+ const siblingVersion = getMilestoneInfo(wt).value?.version ?? '';
1533
+ if (typeof siblingProject === 'string'
1534
+ && siblingProject.trim() === bracketContext.project
1535
+ && milestoneToken(siblingVersion) === bracketContext.milestone) {
1536
+ addBracketRoadmapPhaseNumbers(extractCurrentMilestone(content, wt), used, bracketContext);
1537
+ }
1538
+ continue;
1277
1539
  }
1540
+ // Headers only for non-bracket repos, as before #5528 (#3849).
1541
+ for (const num of roadmapDeclaredPhaseNums(content, false))
1542
+ used.add(num);
1278
1543
  }
1279
1544
  catch {
1280
1545
  /* no roadmap in that worktree (or scope) — normal, contributes nothing */
1281
1546
  }
1282
1547
  }
1283
1548
  }
1549
+ /** The highest used number (a loop, not `Math.max(...set)`, which overflows the argument limit on a large set). */
1550
+ function maxPhaseNum(used) {
1551
+ let max = 0;
1552
+ for (const n of used)
1553
+ if (n > max)
1554
+ max = n;
1555
+ return max;
1556
+ }
1557
+ /** The number after `max`, stepping past the backlog sentinel (999) so allocation never lands on one. */
1558
+ function nextPhaseNum(max) {
1559
+ let n = max + 1;
1560
+ while (isSentinelPhaseId(n))
1561
+ n += 1;
1562
+ return n;
1563
+ }
1564
+ /** Phase numbers already taken: the current milestone's text, phase directories, sibling worktrees (#1229, #3849, #5528). */
1565
+ function collectUsedPhaseNums(cwd, content) {
1566
+ const used = new Set(roadmapDeclaredPhaseNums(content));
1567
+ collectPhaseDirNums(node_path_1.default.join(planningDir(cwd), 'phases'), used);
1568
+ collectSiblingWorktreePhaseNums(cwd, used);
1569
+ return used;
1570
+ }
1571
+ /**
1572
+ * #4304: `toDir` throws a raw `Error` for a description whose
1573
+ * slug sanitizes to empty (e.g. a description that transliterates to
1574
+ * nothing) or is all-digit. An uncaught throw from inside a mutation loop
1575
+ * is worse than a refusal — it can leave earlier iterations' directories
1576
+ * already created with no clean error surface. This wraps that one call so
1577
+ * every bracket phase-directory allocation refuses through `error(...)`
1578
+ * (clean stderr message, controlled exit) instead of crashing.
1579
+ */
1580
+ function bracketDirNameOrRefuse(id, slug, description) {
1581
+ try {
1582
+ return toDir(id, slug);
1583
+ }
1584
+ catch (e) {
1585
+ const msg = e instanceof Error ? e.message : String(e);
1586
+ return error(`Cannot create a phase directory for "${description}": ${msg}`);
1587
+ }
1588
+ }
1589
+ /**
1590
+ * #4304: fail closed before a bracket writer creates or renames into a phase
1591
+ * directory. Allocation intentionally ignores symlink entries, so the exact
1592
+ * destination must be checked with lstat (including dangling links) before a
1593
+ * recursive mkdir or child write can follow it. The canonical containment
1594
+ * predicate then resolves the existing path, or its nearest existing parent,
1595
+ * so a destination reached through a symlinked ancestor cannot escape the
1596
+ * planning phases directory either. A pre-pass supplies simulated occupancy
1597
+ * after deletion and earlier renames; the rename loop checks the live disk.
1598
+ */
1599
+ function assertPhaseDirectoryDestinationSafe(phasesDir, dirName, operation, occupiedNames) {
1600
+ const destination = node_path_1.default.join(phasesDir, dirName);
1601
+ let stat = null;
1602
+ try {
1603
+ stat = node_fs_1.default.lstatSync(destination);
1604
+ }
1605
+ catch (e) {
1606
+ const code = e.code;
1607
+ if (code !== 'ENOENT') {
1608
+ error(`Cannot ${operation} phase directory "${dirName}": unable to inspect the destination (${code ?? 'unknown error'})`);
1609
+ }
1610
+ }
1611
+ if (stat?.isSymbolicLink()) {
1612
+ error(`Cannot ${operation} phase directory "${dirName}": the destination is a symbolic link`);
1613
+ }
1614
+ const destinationOccupied = occupiedNames
1615
+ ? occupiedNames.has(dirName) || (stat !== null && !stat.isDirectory())
1616
+ : stat !== null;
1617
+ if (operation === 'renumber into' && destinationOccupied) {
1618
+ error(`Cannot ${operation} phase directory "${dirName}": the destination already exists`);
1619
+ }
1620
+ if ((0, security_cjs_1.tryWithinRoot)(destination, phasesDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot) === null) {
1621
+ error(`Cannot ${operation} phase directory "${dirName}": the destination resolves outside the planning phases directory`);
1622
+ }
1623
+ }
1624
+ /**
1625
+ * #4304: the bracket writers mint every identity from the milestone's own
1626
+ * numbering. A caller-supplied `--id` or `phase_naming: "custom"` has no
1627
+ * bracket write semantics yet: #5067 owns their precedence (a supplied id is
1628
+ * validated against the resolved convention, "custom" keeps bypassing
1629
+ * generation). Until it lands, refuse before any write rather than discard
1630
+ * the id or override the naming mode.
1631
+ */
1632
+ function assertBracketGeneratedIdentity(command, config, customId) {
1633
+ if (customId) {
1634
+ error(`${command}: --id ${JSON.stringify(customId)} is not supported under phase_id_convention "bracket"; ` +
1635
+ 'bracket phase identities are generated from the active milestone. ' +
1636
+ 'Custom id precedence under the bracket convention lands with #5067.');
1637
+ }
1638
+ if (config['phase_naming'] === 'custom') {
1639
+ error(`${command}: phase_naming "custom" is not supported under phase_id_convention "bracket"; ` +
1640
+ 'bracket phase identities are generated from the active milestone. ' +
1641
+ 'Custom naming precedence under the bracket convention lands with #5067.');
1642
+ }
1643
+ }
1644
+ /**
1645
+ * #4304: refuse a bracket `phase add` / `add-batch` whose located milestone
1646
+ * window is a decoy. A non-milestone heading carrying the active version
1647
+ * token ahead of the real milestone heading (`## Goals for v2.0` before
1648
+ * `## [CK.02] v2.0 Current`) is where the read side locates the window, so
1649
+ * the new entry would land in the decoy section, outside every reader's view
1650
+ * of the milestone. The window is accepted when its own heading carries the
1651
+ * active bracket qualifier; otherwise a live (unfenced, not historical)
1652
+ * milestone heading elsewhere that carries both the active qualifier and the
1653
+ * active version token is the real one, and the writer refuses before any
1654
+ * write, naming both lines. Read-side window selection is unchanged.
1655
+ */
1656
+ function assertBracketAddWindowLocated(rawContent, cwd, context, command) {
1657
+ const ranges = currentMilestoneRawRanges(rawContent, cwd, 'bracket');
1658
+ if (!ranges)
1659
+ return;
1660
+ const lineAt = (offset) => {
1661
+ const end = rawContent.indexOf('\n', offset);
1662
+ return rawContent.slice(offset, end === -1 ? rawContent.length : end).replace(/\r$/, '');
1663
+ };
1664
+ const qualifierRe = new RegExp(`^\\[${(0, pattern_cjs_1.escapeRegex)(context.project)}\\.${(0, pattern_cjs_1.escapeRegex)(context.milestone)}\\]`, 'i');
1665
+ const windowHeading = lineAt(ranges.primary.start);
1666
+ if (qualifierRe.test(windowHeading.replace(/^#+[ \t]*/, '')))
1667
+ return;
1668
+ const version = String(getMilestoneInfo(cwd).value?.version ?? '');
1669
+ if (!version)
1670
+ return;
1671
+ const versionRe = new RegExp(`(?<![\\w.])v?${(0, pattern_cjs_1.escapeRegex)(version.replace(/^v/i, ''))}(?![\\w.-])`, 'i');
1672
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(rawContent);
1673
+ const insideWindow = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
1674
+ || Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end);
1675
+ const realHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(rawContent).find((heading) => !insideWindow(heading.offset)
1676
+ && !historicalLineStarts.has(heading.offset)
1677
+ && rawContent[heading.offset] === '#'
1678
+ && isRecognizedMilestoneHeading(heading.text, heading.level)
1679
+ && qualifierRe.test(heading.text.trim())
1680
+ && versionRe.test(heading.text));
1681
+ if (!realHeading)
1682
+ return;
1683
+ const lineNumber = (offset) => rawContent.slice(0, offset).split('\n').length;
1684
+ error(`${command}: the active milestone window was located at ROADMAP.md line ${lineNumber(ranges.primary.start)} `
1685
+ + `(${JSON.stringify(windowHeading.trim())}), but the milestone heading for ${renderMilestoneId(context)} `
1686
+ + `${version} is at line ${lineNumber(realHeading.offset)} (${JSON.stringify(lineAt(realHeading.offset).trim())}). `
1687
+ + 'Writing the new phase there would place it outside the milestone. Rename or remove the earlier heading '
1688
+ + 'so it no longer carries the active version token, then retry.');
1689
+ }
1284
1690
  function cmdPhaseAdd(cwd, description, raw, customId) {
1285
1691
  if (!description) {
1286
1692
  error('description required for phase add');
1287
1693
  }
1288
1694
  assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
1695
+ const convention = resolvePhaseIdConvention(cwd);
1696
+ // The naming projection reads config silently, so a corrupt project config is
1697
+ // reported as `config_parse_failed` before the loader's own diagnostics run.
1698
+ const naming = phaseDirectoryOptions(cwd, undefined, '');
1699
+ if (customId != null && convention !== 'bracket') {
1700
+ const usage = phaseLocatorMod.customPhaseNamingUsage(cwd, naming);
1701
+ if (usage)
1702
+ error(usage, naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
1703
+ }
1289
1704
  const config = loadConfig(cwd);
1290
1705
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1291
1706
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1292
1707
  error('ROADMAP.md not found');
1293
1708
  }
1294
1709
  const slug = generateSlugInternal(description) || '';
1295
- const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
1710
+ if (convention === 'bracket')
1711
+ assertBracketGeneratedIdentity('phase add', config, customId);
1712
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1713
+ const { newPhaseId, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1296
1714
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1715
+ if (bracketContext)
1716
+ assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add');
1297
1717
  const content = extractCurrentMilestone(rawContent, cwd);
1298
- const projectCode = config.project_code || '';
1299
- const prefix = projectCode ? `${projectCode}-` : '';
1300
1718
  let _newPhaseId;
1301
- let _dirName;
1302
- if (customId || config.phase_naming === 'custom') {
1719
+ let _dirName = '';
1720
+ if (bracketContext) {
1721
+ const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1722
+ const usedPhaseNums = collectBracketPhaseNumbers(content, phasesOnDisk, bracketContext);
1723
+ collectSiblingWorktreePhaseNums(cwd, usedPhaseNums, bracketContext);
1724
+ _newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
1725
+ _dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, _newPhaseId), slug, description);
1726
+ }
1727
+ else if (naming.phaseNaming === 'custom') {
1303
1728
  _newPhaseId = customId || slug.toUpperCase();
1304
1729
  if (!_newPhaseId)
1305
1730
  error('--id required when phase_naming is "custom"');
1306
- _dirName = `${prefix}${_newPhaseId}-${slug}`;
1307
1731
  }
1308
1732
  else {
1309
- // Collect all phase numbers visible in the current-milestone content.
1310
- // Three sources are scanned so that a phase in ANY representation
1311
- // (section header, roadmap bullet, or on-disk directory) is counted:
1312
- // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
1313
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1314
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1315
- // (src/phase-id.cjs) with the LABEL_ONLY baseline and no convention
1316
- // argument — a counter site, matching phase-id.cts's own LABEL_ONLY
1317
- // docstring guidance, reproducing the prior `Phase\s+` literal (no
1318
- // bracket tolerance) byte-for-byte. See collectSiblingWorktreePhaseNums
1319
- // (above) for the identical migration on the same regex shape.
1320
- const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1321
- // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
1322
- // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
1323
- // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
1324
- // are counted and cannot collide with a freshly-added phase. (#1229)
1325
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1326
- const usedPhaseNums = new Set();
1327
- let m;
1328
- while ((m = headerPattern.exec(content)) !== null) {
1329
- const num = parseInt(m[phaseNumGroup], 10);
1330
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1331
- if (!isSentinelPhaseId(num))
1332
- usedPhaseNums.add(num);
1333
- }
1334
- while ((m = bulletPattern.exec(content)) !== null) {
1335
- const num = parseInt(m[1], 10);
1336
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1337
- if (!isSentinelPhaseId(num))
1338
- usedPhaseNums.add(num);
1339
- }
1340
- // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
1341
- const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1342
- if (node_fs_1.default.existsSync(phasesOnDisk)) {
1343
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1344
- for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
1345
- const match = entry.match(dirNumPattern);
1346
- if (!match)
1347
- continue;
1348
- const num = parseInt(match[1], 10);
1349
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1350
- if (!isSentinelPhaseId(num))
1351
- usedPhaseNums.add(num);
1352
- }
1353
- }
1354
- // phase.add appends after the highest *used* number. Collecting numbers from
1355
- // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
1356
- // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
1357
- // an existing number.
1358
- // 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
1359
- // taken on another branch is still taken.
1360
- collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
1361
- const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
1362
- _newPhaseId = maxUsed + 1;
1363
- const paddedNum = String(_newPhaseId).padStart(2, '0');
1364
- _dirName = `${prefix}${paddedNum}-${slug}`;
1733
+ // Collect all phase numbers visible in the current-milestone content. Three sources are
1734
+ // scanned so a phase in ANY representation (section header, roadmap bullet, or on-disk
1735
+ // directory) is counted: a bullet-only Phase N must not collide with a fresh one (#1229),
1736
+ // and under the bracket convention `[CODE.MM] NN` forms count in all three (#5528).
1737
+ // Header/bullet scans are #1729 pre-colon-tag tolerant and #3185 sentinel-filtered.
1738
+ const usedPhaseNums = collectUsedPhaseNums(cwd, content);
1739
+ _newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
1365
1740
  }
1366
- const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1367
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1368
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1369
- const dependsOn = config.phase_naming === 'custom' ? '' : dependsOnLine(_newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1370
- const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
1371
- const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1372
- const updatedContent = spliceRoadmapBlock(rawContent, insertAt, phaseEntry);
1373
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent, { verbatim: true });
1374
- return { newPhaseId: _newPhaseId, dirName: _dirName };
1741
+ if (bracketContext) {
1742
+ const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1743
+ assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
1744
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1745
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1746
+ }
1747
+ else {
1748
+ [{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
1749
+ id: String(_newPhaseId), slug,
1750
+ directoryId: typeof _newPhaseId === 'number' ? String(_newPhaseId).padStart(2, '0') : _newPhaseId,
1751
+ }], { ...naming, roadmap: content });
1752
+ }
1753
+ let phaseEntry;
1754
+ if (bracketContext && typeof _newPhaseId === 'number') {
1755
+ const id = bracketPhaseId(bracketContext, _newPhaseId);
1756
+ const display = renderPhaseId(id);
1757
+ const declared = new Set();
1758
+ addBracketRoadmapPhaseNumbers(content, declared);
1759
+ const dependsOn = dependsOnLine(_newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
1760
+ phaseEntry =
1761
+ `\n### ${display}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
1762
+ }
1763
+ else {
1764
+ const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(_newPhaseId, rawContent);
1765
+ phaseEntry =
1766
+ `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
1767
+ }
1768
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
1769
+ const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, _newPhaseId, description, undefined, bracketContext && typeof _newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, _newPhaseId)) : undefined);
1770
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, recorded.content, { verbatim: true });
1771
+ return { newPhaseId: _newPhaseId, dirName: _dirName, roadmapEntry: recorded.entry };
1375
1772
  });
1376
1773
  const titleWarning = describeGoalShapedTitle(description);
1377
1774
  const result = {
@@ -1380,7 +1777,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1380
1777
  name: description,
1381
1778
  slug,
1382
1779
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1383
- naming_mode: config.phase_naming,
1780
+ naming_mode: naming.phaseNaming,
1781
+ roadmap_entry: roadmapEntry,
1384
1782
  };
1385
1783
  if (titleWarning)
1386
1784
  result['warning'] = titleWarning;
@@ -1409,94 +1807,111 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1409
1807
  assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
1410
1808
  }
1411
1809
  const config = loadConfig(cwd);
1810
+ const convention = resolvePhaseIdConvention(cwd);
1811
+ const naming = phaseDirectoryOptions(cwd, undefined, '');
1812
+ if (descriptions.includes('--id') && convention !== 'bracket') {
1813
+ error(phaseLocatorMod.customPhaseNamingUsage(cwd, naming) ?? '--id is not supported by phase add-batch', naming.phaseNaming === 'custom' ? ERROR_REASON.USAGE : naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
1814
+ }
1412
1815
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1413
1816
  if (!node_fs_1.default.existsSync(roadmapPath)) {
1414
1817
  error('ROADMAP.md not found');
1415
1818
  }
1416
- const projectCode = config.project_code || '';
1417
- const prefix = projectCode ? `${projectCode}-` : '';
1819
+ if (convention === 'bracket')
1820
+ assertBracketGeneratedIdentity('phase add-batch', config);
1821
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1418
1822
  const results = withPlanningLock(cwd, () => {
1419
1823
  let rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1824
+ if (bracketContext)
1825
+ assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add-batch');
1420
1826
  const content = extractCurrentMilestone(rawContent, cwd);
1421
1827
  let maxPhase = 0;
1422
- if (config.phase_naming !== 'custom') {
1423
- // Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
1424
- // bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
1425
- // `Phase N` row was invisible to batch allocation (#3849 secondary).
1426
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1427
- // #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
1428
- // (src/phase-id.cjs) with the LABEL_ONLY baseline and no convention
1429
- // argument — same counter-site migration as cmdPhaseAdd's identical
1430
- // regex above.
1431
- const { regex: phasePattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
1432
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
1433
- let m;
1434
- while ((m = phasePattern.exec(content)) !== null) {
1435
- const num = parseInt(m[phaseNumGroup], 10);
1436
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1437
- if (isSentinelPhaseId(num))
1438
- continue;
1439
- if (num > maxPhase)
1440
- maxPhase = num;
1441
- }
1442
- while ((m = bulletPattern.exec(content)) !== null) {
1443
- const num = parseInt(m[1], 10);
1444
- if (isSentinelPhaseId(num))
1445
- continue;
1446
- if (num > maxPhase)
1447
- maxPhase = num;
1448
- }
1449
- const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
1450
- if (node_fs_1.default.existsSync(phasesOnDisk)) {
1451
- const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
1452
- for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
1453
- const match = entry.match(dirNumPattern);
1454
- if (!match)
1455
- continue;
1456
- const num = parseInt(match[1], 10);
1457
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1458
- if (isSentinelPhaseId(num))
1459
- continue;
1460
- if (num > maxPhase)
1461
- maxPhase = num;
1462
- }
1463
- }
1464
- // 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
1465
- const siblingNums = new Set();
1466
- collectSiblingWorktreePhaseNums(cwd, siblingNums);
1467
- for (const num of siblingNums) {
1468
- if (num > maxPhase)
1469
- maxPhase = num;
1470
- }
1828
+ if (bracketContext) {
1829
+ const used = collectBracketPhaseNumbers(content, node_path_1.default.join(planningDir(cwd), 'phases'), bracketContext);
1830
+ collectSiblingWorktreePhaseNums(cwd, used, bracketContext);
1831
+ maxPhase = maxPhaseNum(used);
1471
1832
  }
1472
- const added = [];
1833
+ else if (naming.phaseNaming !== 'custom') {
1834
+ // Same three cwd-scoped sources as cmdPhaseAdd (#1229, #5528): headers, roadmap bullets,
1835
+ // on-disk dirs, then sibling worktrees (#3849) — same max+1, wider horizon.
1836
+ maxPhase = maxPhaseNum(collectUsedPhaseNums(cwd, content));
1837
+ }
1838
+ // #4304: compute and validate every item's slug/dirName
1839
+ // BEFORE the first `platformEnsureDir` — a bracket `toDir` failure
1840
+ // (e.g. a description that sanitizes to an empty slug) must refuse the
1841
+ // whole batch with zero directories created, not throw mid-loop after
1842
+ // earlier items already created theirs. This first pass touches no
1843
+ // disk.
1844
+ const validated = [];
1473
1845
  for (const description of descriptions) {
1474
1846
  const slug = generateSlugInternal(description) || '';
1475
1847
  let newPhaseId;
1476
1848
  let dirName;
1477
- if (config.phase_naming === 'custom') {
1849
+ if (bracketContext) {
1850
+ maxPhase = nextPhaseNum(maxPhase);
1851
+ newPhaseId = maxPhase;
1852
+ dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, newPhaseId), slug, description);
1853
+ }
1854
+ else if (naming.phaseNaming === 'custom') {
1478
1855
  newPhaseId = slug.toUpperCase();
1479
- dirName = `${prefix}${newPhaseId}-${slug}`;
1856
+ dirName = '';
1480
1857
  }
1481
1858
  else {
1482
- maxPhase += 1;
1859
+ maxPhase = nextPhaseNum(maxPhase);
1483
1860
  newPhaseId = maxPhase;
1484
- dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
1861
+ dirName = '';
1485
1862
  }
1486
- const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', dirName);
1487
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1488
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1489
- const dependsOn = config.phase_naming === 'custom' ? '' : dependsOnLine(newPhaseId, roadmapDeclaredPhaseNums(rawContent));
1490
- const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
1491
- const insertAt = phaseEntryInsertOffset(rawContent, cwd);
1492
- rawContent = spliceRoadmapBlock(rawContent, insertAt, phaseEntry);
1863
+ validated.push({ description, slug, newPhaseId, dirName });
1864
+ }
1865
+ // Batch creation is all-or-nothing: validate every bracket destination
1866
+ // before the first directory or ROADMAP byte is written.
1867
+ if (bracketContext) {
1868
+ const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1869
+ for (const { dirName } of validated) {
1870
+ assertPhaseDirectoryDestinationSafe(phasesDir, dirName, 'create');
1871
+ }
1872
+ }
1873
+ if (!bracketContext) {
1874
+ // One preflight over the whole batch (identity collisions, occupied
1875
+ // targets, containment) before the first directory is created.
1876
+ const prepared = phaseLocatorMod.createPhaseDirectories(cwd, validated.map(item => ({
1877
+ id: String(item.newPhaseId), slug: item.slug,
1878
+ directoryId: typeof item.newPhaseId === 'number' ? String(item.newPhaseId).padStart(2, '0') : item.newPhaseId,
1879
+ })), { ...naming, roadmap: content });
1880
+ prepared.forEach((entry, index) => { validated[index].dirName = entry.dirName; });
1881
+ }
1882
+ const added = [];
1883
+ for (const { description, slug, newPhaseId, dirName } of validated) {
1884
+ if (bracketContext) {
1885
+ const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', dirName);
1886
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1887
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1888
+ }
1889
+ let phaseEntry;
1890
+ if (bracketContext && typeof newPhaseId === 'number') {
1891
+ const id = bracketPhaseId(bracketContext, newPhaseId);
1892
+ const declared = new Set();
1893
+ // rawContent includes earlier batch items, which become prerequisites.
1894
+ addBracketRoadmapPhaseNumbers(extractCurrentMilestone(rawContent, cwd), declared);
1895
+ const dependsOn = dependsOnLine(newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
1896
+ phaseEntry =
1897
+ `\n### ${renderPhaseId(id)}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
1898
+ }
1899
+ else {
1900
+ const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(newPhaseId, rawContent);
1901
+ phaseEntry =
1902
+ `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
1903
+ }
1904
+ const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
1905
+ const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, newPhaseId, description, undefined, bracketContext && typeof newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, newPhaseId)) : undefined);
1906
+ rawContent = recorded.content;
1493
1907
  added.push({
1908
+ roadmap_entry: recorded.entry,
1494
1909
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
1495
1910
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
1496
1911
  name: description,
1497
1912
  slug,
1498
1913
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
1499
- naming_mode: config.phase_naming,
1914
+ naming_mode: naming.phaseNaming,
1500
1915
  });
1501
1916
  }
1502
1917
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, rawContent, { verbatim: true });
@@ -1570,13 +1985,199 @@ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
1570
1985
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
1571
1986
  decimalSet.add(parseInt(rmMatch[1], 10));
1572
1987
  }
1573
- const checklistDecimalPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1988
+ const checklistDecimalPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
1574
1989
  let clMatch;
1575
1990
  while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
1576
1991
  decimalSet.add(parseInt(clMatch[1], 10));
1577
1992
  }
1578
1993
  return decimalSet;
1579
1994
  }
1995
+ function scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent, base, context, meta) {
1996
+ // A bracket repository can be mid-migration, and add-backlog deliberately
1997
+ // remains a legacy sentinel writer. Inventory is therefore the UNION of
1998
+ // the legacy spellings and the canonical bracket spellings that readers
1999
+ // accept. Do not route the 999 parent exclusively through
2000
+ // scanMilestonePhaseIds: that reader intentionally excludes the icebox
2001
+ // range for milestone counting, while allocation must include it.
2002
+ const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, base);
2003
+ for (const token of scanMilestonePhaseIds(roadmapContent, 'bracket')) {
2004
+ const [phase, subphase, extra] = String(token).split('.');
2005
+ if (!extra && Number(phase) === Number(base) && /^\d+$/.test(subphase ?? '')) {
2006
+ decimalSet.add(Number(subphase));
2007
+ }
2008
+ }
2009
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
2010
+ if (Number(id.phase) !== Number(base))
2011
+ continue;
2012
+ if (meta)
2013
+ meta.bracketSpellingFound = true;
2014
+ if (id.subphase)
2015
+ decimalSet.add(Number(id.subphase));
2016
+ }
2017
+ const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, 'bracket', true);
2018
+ const entryPattern = new RegExp(`^${intro}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[ \\t]*:`, 'i');
2019
+ const addRoadmapToken = (bracketId, token) => {
2020
+ const [phase, subphase, extra] = token.split('.');
2021
+ if (extra || Number(phase) !== Number(base) || !/^\d+$/.test(subphase ?? ''))
2022
+ return;
2023
+ if (bracketId) {
2024
+ if (foldBracketId(bracketId) !== foldBracketId(`${context.project}.${context.milestone}`))
2025
+ return;
2026
+ if (meta)
2027
+ meta.bracketSpellingFound = true;
2028
+ }
2029
+ decimalSet.add(Number(subphase));
2030
+ };
2031
+ // tokenizeHeadings is the fence-aware reader surface and, unlike the
2032
+ // milestone membership scanner, retains sentinel headings for this
2033
+ // allocation-specific inventory.
2034
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
2035
+ if (heading.level < 2 || heading.level > 4)
2036
+ continue;
2037
+ const match = entryPattern.exec(heading.text);
2038
+ if (match)
2039
+ addRoadmapToken(match[1], match[2]);
2040
+ }
2041
+ // The manager accepts bracket checklist rows without requiring a detail
2042
+ // heading. Match that same phase-intro grammar here so ROADMAP-only children
2043
+ // still reserve their number. Fenced examples are documentation, not live
2044
+ // inventory.
2045
+ const fencedLines = fencedRoadmapLineNumbers(roadmapContent);
2046
+ for (const [index, line] of roadmapContent.split('\n').entries()) {
2047
+ if (fencedLines.has(index + 1))
2048
+ continue;
2049
+ const checklist = parsePhaseChecklistLine(line.replace(/\r$/, ''), 'bracket');
2050
+ if (checklist)
2051
+ addRoadmapToken(checklist.bracketId, checklist.phaseToken);
2052
+ }
2053
+ return decimalSet;
2054
+ }
2055
+ /**
2056
+ * #4304: the ONE bracket-argument canonicalization `phase
2057
+ * remove` and `phase insert` both need, extracted from cmdPhaseRemove's own
2058
+ * canonicalization so insert accepts every form remove does instead of maintaining a
2059
+ * second, narrower copy that could silently disagree with it. A bare token
2060
+ * (`2`, `02`, `002`) canonicalizes through phase-id-display's `phaseToken`
2061
+ * adapter; a qualified/display token (`CK.02-02`, `[CK.02] 02`) canonicalizes
2062
+ * through phase-id.cts's strict `parsePhaseId`, refusing before any mutation
2063
+ * when it names a different milestone. `actionVerb` (e.g. "remove", "insert
2064
+ * after") only varies the refusal wording.
2065
+ */
2066
+ function canonicalizeBracketPhaseArgument(context, targetPhase, actionVerb) {
2067
+ const isQualified = targetPhase.startsWith('[') || targetPhase.includes('-');
2068
+ if (isQualified) {
2069
+ let qualifiedId = null;
2070
+ try {
2071
+ if (targetPhase.startsWith('[')) {
2072
+ const close = targetPhase.indexOf(']');
2073
+ // The owned-line reader canonicalizes numeric segments via phaseToken
2074
+ // before strict identity parsing; CLI display ids use that same owner.
2075
+ qualifiedId = phaseIdFromOwnedParts(targetPhase.slice(1, close), targetPhase.slice(close + 1).trim());
2076
+ }
2077
+ else {
2078
+ qualifiedId = parsePhaseId(targetPhase);
2079
+ }
2080
+ }
2081
+ catch {
2082
+ return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
2083
+ }
2084
+ if (qualifiedId === null) {
2085
+ return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
2086
+ }
2087
+ if (qualifiedId.project !== context.project || qualifiedId.milestone !== context.milestone) {
2088
+ error(`Phase ${targetPhase} belongs to milestone [${qualifiedId.project}.${qualifiedId.milestone}], `
2089
+ + `but the active milestone is [${context.project}.${context.milestone}]. `
2090
+ + `Refusing to ${actionVerb} a phase outside the active milestone.`);
2091
+ }
2092
+ const isDecimal = qualifiedId.subphase !== undefined;
2093
+ const prefix = bracketDirPrefix(qualifiedId);
2094
+ const slug = targetPhase.startsWith(prefix) ? targetPhase.slice(prefix.length) : undefined;
2095
+ return { normalized: isDecimal ? `${qualifiedId.phase}.${qualifiedId.subphase}` : qualifiedId.phase, isDecimal, slug };
2096
+ }
2097
+ const canonicalToken = phaseToken(targetPhase);
2098
+ if (canonicalToken === null) {
2099
+ error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`);
2100
+ return { normalized: '', isDecimal: false };
2101
+ }
2102
+ return { normalized: canonicalToken, isDecimal: canonicalToken.includes('.') };
2103
+ }
2104
+ /**
2105
+ * Select `phase insert`'s bracket target from the reader-owned active ranges.
2106
+ * A raw range may still contain a shipped `<details>` archive, so range
2107
+ * membership alone is not evidence that a same-numbered heading is live.
2108
+ * Reuse bracket removal's historical-section classifier and owned-line parser:
2109
+ * the latter derives its bracket intro from phase-id.cts's exported read
2110
+ * grammar, then `sameBracketPhaseId` qualifies project, milestone, and the
2111
+ * canonical phase number together. The insertion boundary comes from the same
2112
+ * fence-aware heading tokens, never a raw next-heading regex. A fenced block
2113
+ * that contains a phase-heading example is kept byte-identical and begins a
2114
+ * protected boundary, so the live inserted heading lands before the example
2115
+ * instead of inside its fence. No bare-number fallback exists here.
2116
+ */
2117
+ function activeBracketInsertHeading(content, targetId, ranges) {
2118
+ if (!ranges)
2119
+ return null;
2120
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
2121
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
2122
+ const searchRanges = [ranges.primary, ...(ranges.details ? [ranges.details] : [])];
2123
+ const lines = splitRoadmapLineRecords(content);
2124
+ const lineByStart = new Map(lines.map((line) => [line.start, line]));
2125
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2126
+ const rawLines = content.split('\n');
2127
+ const protectedFenceStarts = (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)
2128
+ .filter((block) => {
2129
+ const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
2130
+ for (let index = block.openLineIdx + 1; index < lastIndex; index++) {
2131
+ if (classifyBracketOwnedLine(rawLines[index].replace(/\r$/, '')).kind === 'heading')
2132
+ return true;
2133
+ }
2134
+ return false;
2135
+ })
2136
+ .map((block) => lines[block.openLineIdx]?.start)
2137
+ .filter((start) => start !== undefined);
2138
+ for (const range of searchRanges) {
2139
+ const liveHeadings = headings.filter((heading) => heading.offset >= range.start
2140
+ && heading.offset < range.end
2141
+ && !historicalLineStarts.has(heading.offset));
2142
+ for (let headingIndex = 0; headingIndex < liveHeadings.length; headingIndex++) {
2143
+ const heading = liveHeadings[headingIndex];
2144
+ const line = lineByStart.get(heading.offset);
2145
+ if (!line || fencedLineNumbers.has(line.lineNumber))
2146
+ continue;
2147
+ const owned = classifyBracketOwnedLine(line.text);
2148
+ if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId))
2149
+ continue;
2150
+ let insertAt = range.end;
2151
+ for (const nextHeading of liveHeadings.slice(headingIndex + 1)) {
2152
+ const nextLine = lineByStart.get(nextHeading.offset);
2153
+ if (!nextLine)
2154
+ continue;
2155
+ const nextOwned = classifyBracketOwnedLine(nextLine.text);
2156
+ if (nextOwned.kind !== 'heading')
2157
+ continue;
2158
+ // #5599: bracket ids have one subphase level, so both nested and
2159
+ // sibling allocations append after this phase's existing children.
2160
+ if (nextOwned.id
2161
+ && nextOwned.id.project === targetId.project
2162
+ && nextOwned.id.milestone === targetId.milestone
2163
+ && isPhaseTokenWithin(bracketArtifactToken(nextOwned.id), targetId.phase))
2164
+ continue;
2165
+ insertAt = nextHeading.offset;
2166
+ break;
2167
+ }
2168
+ for (const fenceStart of protectedFenceStarts) {
2169
+ if (fenceStart > heading.offset && fenceStart < insertAt)
2170
+ insertAt = fenceStart;
2171
+ }
2172
+ for (const historicalStart of historicalLineStarts) {
2173
+ if (historicalStart > heading.offset && historicalStart < insertAt)
2174
+ insertAt = historicalStart;
2175
+ }
2176
+ return { start: line.start, length: line.text.length + line.eol.length, insertAt };
2177
+ }
2178
+ }
2179
+ return null;
2180
+ }
1580
2181
  /**
1581
2182
  * #5599: true when `token` (a heading/bullet phase number such as "13.1.2") is
1582
2183
  * a STRICT descendant of `ancestor` ("13"). Compared segment-wise so "130" is
@@ -1592,6 +2193,34 @@ function isPhaseTokenWithin(token, ancestor) {
1592
2193
  const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
1593
2194
  return a.every((segment, i) => canon(segment) === canon(t[i]));
1594
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
+ }
1595
2224
  function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
1596
2225
  if (!afterPhase || !description) {
1597
2226
  error('after-phase and description required for phase insert');
@@ -1602,23 +2231,55 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1602
2231
  error('ROADMAP.md not found');
1603
2232
  }
1604
2233
  const slug = generateSlugInternal(description) || '';
1605
- const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
2234
+ const insertConfig = loadConfig(cwd);
2235
+ const convention = resolvePhaseIdConvention(cwd);
2236
+ const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, insertConfig) : null;
2237
+ const { decimalPhase, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1606
2238
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1607
2239
  const content = extractCurrentMilestone(rawContent, cwd);
1608
- const normalizedAfter = normalizePhaseName(afterPhase);
2240
+ // #4304: canonicalize a bracket argument
2241
+ // through the SAME adapter `phase remove` uses
2242
+ // (canonicalizeBracketPhaseArgument) instead of the legacy
2243
+ // normalizePhaseName, which pads only the phase's FIRST segment ("1.1"
2244
+ // -> "01.1", never matching the bracket-canonical "01.01" heading) and
2245
+ // never accepted a qualified/display form ("CK.02-02", "[CK.02] 02") at
2246
+ // all. Every other convention keeps its untouched normalizePhaseName
2247
+ // behavior.
2248
+ let normalizedAfter;
2249
+ if (bracketContext) {
2250
+ normalizedAfter = canonicalizeBracketPhaseArgument(bracketContext, afterPhase, 'insert after').normalized;
2251
+ }
2252
+ else {
2253
+ normalizedAfter = normalizePhaseName(afterPhase);
2254
+ }
2255
+ // #4304: a bracket id supports at most one decimal level
2256
+ // (phase.subphase). Nesting one level deeper under an already-decimal
2257
+ // afterPhase would produce a three-level id no bracket function can
2258
+ // represent — bracketPhaseId/toDir would otherwise throw uncaught deep
2259
+ // inside directory/heading computation instead of refusing cleanly,
2260
+ // before any mutation. Sibling allocation is unaffected: it joins
2261
+ // afterPhase's PARENT level, which is always representable.
2262
+ if (bracketContext && allocation === 'nested' && normalizedAfter.includes('.')) {
2263
+ error(`Cannot insert a nested sub-phase under phase ${normalizedAfter}: bracket phase ids support at most one decimal level. Use --sibling, or insert after the parent phase instead.`);
2264
+ }
1609
2265
  const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
1610
2266
  const insertConvention = resolvePhaseIdConvention(cwd);
1611
- // #5007 (Phase 6 / ADR-4910 §8): buildPhaseHeadingRegex (src/roadmap.cts)
1612
- // is anchored `^...$` with no 'm' flag — designed to test ONE
1613
- // already-tokenized heading LINE, not to `.test()` against multi-line
1614
- // `content` directly (the design doc's "no anchoring change needed"
1615
- // claim for this site does not hold once the anchor is accounted for —
1616
- // same discrepancy as getRoadmapModeForPhase and phaseDisplayNameFromRoadmap
1617
- // above/below). Routed through tokenizeHeadings, mirroring
1618
- // searchPhaseInContent's own usage.
2267
+ const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention);
1619
2268
  const targetPattern = buildPhaseHeadingRegex(afterPhaseEscaped, insertConvention);
1620
- const headingMatch = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).some((h) => targetPattern.test(h.text));
1621
- const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2269
+ const bracketSectionRanges = bracketContext
2270
+ ? currentMilestoneRawRanges(rawContent, cwd, 'bracket')
2271
+ : null;
2272
+ const [targetPhase, targetSubphase] = normalizedAfter.split('.');
2273
+ const bracketTargetId = bracketContext
2274
+ ? bracketPhaseId(bracketContext, targetPhase, targetSubphase)
2275
+ : null;
2276
+ const bracketHeading = bracketTargetId
2277
+ ? activeBracketInsertHeading(rawContent, bracketTargetId, bracketSectionRanges)
2278
+ : null;
2279
+ const headingMatch = bracketContext
2280
+ ? bracketHeading !== null
2281
+ : (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).some((h) => targetPattern.test(h.text));
2282
+ const bulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1622
2283
  // #5007 (Phase 6 / ADR-4910 §8): a pure "does ANY phase heading exist"
1623
2284
  // boolean — neither shared owner exposes this shape (buildPhaseHeadingRegex
1624
2285
  // is phase-N-specific; buildPhaseHeadingScanRegex always captures a phase
@@ -1629,19 +2290,45 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1629
2290
  // there is no anchor in this source.
1630
2291
  const anyHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}\\d`, 'i');
1631
2292
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
1632
- const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
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.
2294
+ const liveCurrent = blankCodeAndComments(content);
2295
+ const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(liveCurrent) && !roadmapHasHeadingPhases;
2296
+ if (bracketContext && !bracketHeading) {
2297
+ const searchedRanges = bracketSectionRanges?.details
2298
+ ? 'primary and Phase Details ranges'
2299
+ : 'primary range';
2300
+ error(`Could not find live ${renderPhaseId(bracketTargetId)} heading in the active milestone window (${searchedRanges})`);
2301
+ }
1633
2302
  if (!headingMatch && !isBulletStyle) {
1634
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1635
- if (checklistPattern.test(content)) {
2303
+ const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2304
+ if (checklistPattern.test(liveCurrent)) {
1636
2305
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
1637
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
+ }
1638
2317
  error(`Phase ${afterPhase} not found in ROADMAP.md`);
1639
2318
  }
1640
2319
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1641
- const normalizedBase = normalizePhaseName(afterPhase);
1642
- const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
2320
+ // #4304: reuse the SAME canonicalization computed above
2321
+ // (phaseToken on bracket, normalizePhaseName otherwise) rather than a
2322
+ // second, independent normalizePhaseName(afterPhase) call that would
2323
+ // silently disagree with it on bracket.
2324
+ const normalizedBase = normalizedAfter;
2325
+ const decimalSet = bracketContext
2326
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, normalizedBase, bracketContext)
2327
+ : scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
1643
2328
  const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
1644
- let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
2329
+ let selectedBase = normalizedBase;
2330
+ let selectedNextDecimal = nextDecimal;
2331
+ let _decimalPhase = `${selectedBase}.${selectedNextDecimal}`;
1645
2332
  // #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
1646
2333
  // one level deeper under afterPhase itself. A top-level phase (no existing
1647
2334
  // decimal segment) has no sibling level to join; nested is the only sensible
@@ -1649,26 +2336,49 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1649
2336
  const lastDotIndex = normalizedBase.lastIndexOf('.');
1650
2337
  if (allocation === 'sibling' && lastDotIndex !== -1) {
1651
2338
  const parentBase = normalizedBase.slice(0, lastDotIndex);
1652
- const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
2339
+ const siblingDecimalSet = bracketContext
2340
+ ? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, parentBase, bracketContext)
2341
+ : scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
1653
2342
  const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
2343
+ selectedBase = parentBase;
2344
+ selectedNextDecimal = siblingNextDecimal;
1654
2345
  _decimalPhase = `${parentBase}.${siblingNextDecimal}`;
1655
2346
  }
2347
+ const bracketId = bracketContext
2348
+ ? bracketPhaseId(bracketContext, selectedBase, selectedNextDecimal)
2349
+ : null;
2350
+ if (bracketId)
2351
+ _decimalPhase = bracketArtifactToken(bracketId);
1656
2352
  // #5599: the family whose subtree the new entry must land at the end of —
1657
2353
  // the parent for a sibling allocation, the target itself for a nested one.
1658
2354
  const subtreeRoot = allocation === 'sibling' && lastDotIndex !== -1
1659
2355
  ? normalizedBase.slice(0, lastDotIndex)
1660
2356
  : normalizedBase;
1661
- const insertConfig = loadConfig(cwd);
1662
- const projectCode = insertConfig.project_code || '';
1663
- const pfx = projectCode ? `${projectCode}-` : '';
1664
- const _dirName = `${pfx}${_decimalPhase}-${slug}`;
2357
+ const insertNaming = phaseDirectoryOptions(cwd, undefined, rawContent);
2358
+ // #4304: route the bracket directory-name allocation
2359
+ // through the SAME bracketDirNameOrRefuse wrapper `phase add`/`phase
2360
+ // add-batch` already use, instead of a raw `toDir` call —
2361
+ // an empty or all-digit slug now refuses cleanly through error(...)
2362
+ // before any mutation, matching their wording, instead of an uncaught
2363
+ // "toDir: slug sanitizes to empty" throw.
2364
+ let _dirName;
2365
+ if (bracketId) {
2366
+ _dirName = bracketDirNameOrRefuse(bracketId, slug, description);
2367
+ }
2368
+ else {
2369
+ // An inserted child inherits its parent's identity policy: legacy numeric
2370
+ // families must still renumber together when their parent moves.
2371
+ [{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
2372
+ id: _decimalPhase, slug,
2373
+ recordIdentity: phaseIdMod.customPhaseQuery(afterPhase, insertNaming) !== null,
2374
+ }], insertNaming);
2375
+ }
1665
2376
  const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
1666
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
1667
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
1668
2377
  let updatedContent;
2378
+ let roadmapEntry;
1669
2379
  if (isBulletStyle) {
1670
- const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
1671
- const useBold = boldBulletPattern.test(content);
2380
+ const boldBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', { bold: 'required' });
2381
+ const useBold = boldBulletPattern.test(liveCurrent);
1672
2382
  const phaseLabel = useBold
1673
2383
  ? `**Phase ${_decimalPhase}: ${description}**`
1674
2384
  : `Phase ${_decimalPhase}: ${description}`;
@@ -1680,6 +2390,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1680
2390
  // templating it via detectEol(rawContent) was inert dead code. '\n'
1681
2391
  // matches what platformWriteSync enforces anyway.
1682
2392
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
2393
+ // A non-bold bullet is not an entry `roadmap analyze` reads (checklistEntrySrc needs `**`).
2394
+ roadmapEntry = new RegExp(checklistEntrySrc(insertConvention, false, true), 'm').test(bulletEntry) ? 'checklist' : 'none';
1683
2395
  // #3413: was `[^\n]*`, which on CRLF content swallows the line's
1684
2396
  // trailing \r into the match, shifting bulletLineEnd to land BETWEEN
1685
2397
  // the \r and \n of the original CRLF pair — a pure splice-POSITION
@@ -1688,17 +2400,18 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1688
2400
  // .md targets regardless). Widening to [^\r\n]* stops the match at the
1689
2401
  // true line-content boundary so bulletLineEnd lands cleanly before the
1690
2402
  // terminator.
1691
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
1692
- const bulletMatchResult = rawContent.match(targetBulletPattern);
2403
+ const targetBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`, 'i');
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);
1693
2406
  if (!bulletMatchResult) {
1694
2407
  error(`Could not find Phase ${afterPhase} bullet line`);
1695
2408
  }
1696
- const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
2409
+ const bulletLineEnd = bulletMatchResult.index + bulletMatchResult[0].length;
1697
2410
  const afterBullet = rawContent.slice(bulletLineEnd);
1698
2411
  // #5599: land after the last bullet inside the allocation family's
1699
2412
  // subtree, not before the first following bullet of any number (which is
1700
2413
  // the target's own later decimal child when one exists).
1701
- const bulletRe = /\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+(?<tok>\d[^\s:*]*)[^\r\n]*/gi;
2414
+ const bulletRe = new RegExp(`\\r?\\n${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(?<tok>\\d[^\\s:*]*)[^\\r\\n]*`, 'gim');
1702
2415
  let insertIdx = bulletLineEnd;
1703
2416
  for (const m of afterBullet.matchAll(bulletRe)) {
1704
2417
  if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
@@ -1711,7 +2424,13 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1711
2424
  rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
1712
2425
  }
1713
2426
  else {
1714
- const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
2427
+ const headingId = bracketId ? renderPhaseId(bracketId) : `Phase ${_decimalPhase}`;
2428
+ let dependsId = `Phase ${afterPhase}`;
2429
+ if (bracketContext) {
2430
+ const [dependsPhase, dependsSubphase] = normalizedBase.split('.');
2431
+ dependsId = renderPhaseId(bracketPhaseId(bracketContext, dependsPhase, dependsSubphase));
2432
+ }
2433
+ const phaseEntry = `\n### ${headingId}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** ${dependsId}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
1715
2434
  // #5007 (Phase 6 / ADR-4910 §8): this site needs the WHOLE matched
1716
2435
  // heading line plus its trailing newline (for splicing the new entry
1717
2436
  // in immediately after it) — neither shared owner returns that shape
@@ -1721,51 +2440,79 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1721
2440
  // migration above (same describe block, same afterPhaseEscaped/
1722
2441
  // insertConvention inputs).
1723
2442
  const headerPattern = new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i');
1724
- const headerMatch = rawContent.match(headerPattern);
1725
- if (!headerMatch) {
1726
- error(`Could not find Phase ${afterPhase} header`);
2443
+ // #4304: bracket insertion is located by `activeBracketInsertHeading`
2444
+ // above, which requires all three ownership facts together: the line is
2445
+ // inside the reader-owned active window, is not historical under the
2446
+ // shared remove/guard classifier, and parses to the active bracket
2447
+ // identity plus canonical target number through the exported read
2448
+ // grammar. This closes the raw-range hole where an archived `[CK.01]
2449
+ // 01` inside `[CK.02]`'s primary bytes won a bare-number search. The
2450
+ // legacy path below keeps its whole-document pattern byte-for-behavior.
2451
+ //
2452
+ // #4304: the pre-flight headingMatch check above ran
2453
+ // against extractCurrentMilestone's content, which (for a bracket
2454
+ // repo) merges the primary section with a later "(Phase Details)"
2455
+ // section carrying the same milestone identity — so it can pass even
2456
+ // when the target's own detail heading lives ONLY in that Phase
2457
+ // Details range, separated from primary by an unrelated sibling
2458
+ // milestone. Search the SAME two raw ranges bracket removal
2459
+ // discovers (primary, then details) instead of primary alone, so a
2460
+ // heading the pre-flight check can see is also found here.
2461
+ const headerSearchRanges = bracketSectionRanges
2462
+ ? [
2463
+ bracketSectionRanges.primary,
2464
+ ...(bracketSectionRanges.details ? [bracketSectionRanges.details] : []),
2465
+ ]
2466
+ : [{ start: 0, end: rawContent.length }];
2467
+ let searchStart = bracketHeading?.start ?? headerSearchRanges[0].start;
2468
+ let searchEnd = bracketHeading?.insertAt ?? headerSearchRanges[0].end;
2469
+ let headerLength = bracketHeading?.length ?? 0;
2470
+ if (!bracketHeading) {
2471
+ let headerMatch = null;
2472
+ for (const range of headerSearchRanges) {
2473
+ const searchWindow = rawContent.slice(range.start, range.end);
2474
+ const match = searchWindow.match(headerPattern);
2475
+ if (match) {
2476
+ headerMatch = match;
2477
+ searchStart = range.start + match.index;
2478
+ searchEnd = range.end;
2479
+ headerLength = match[0].length;
2480
+ break;
2481
+ }
2482
+ }
2483
+ if (!headerMatch) {
2484
+ error(`Could not find Phase ${afterPhase} header`);
2485
+ }
1727
2486
  }
1728
- const headerIdx = rawContent.indexOf(headerMatch[0]);
1729
- const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
1730
- // #5007 (Phase 6 / ADR-4910 §8): this next-phase-boundary lookup was NOT
1731
- // one of the 15 originally-marked grandfathered sites, but it hand-rolls
1732
- // the identical `#{2,4}...Phase\s+` heading-marker shape and sits right
1733
- // next to headerPattern above, which this same commit series just
1734
- // widened (via insertConvention) to recognize a bracket-tagged target
1735
- // heading. Left bracket-blind, this regex would silently defeat that
1736
- // widening: under the bracket convention a freshly-recognized
1737
- // bracket-tagged target could still fail to find the NEXT heading as a
1738
- // boundary (`[CODE.MM] Phase N+1:` never matches a bare `Phase\s+`
1739
- // literal), so the newly inserted entry falls through to the
1740
- // rawContent.length branch and lands appended at end-of-file instead of
1741
- // directly after the target section. Found while migrating the
1742
- // neighboring sites, not deferred: this is the same defect class the
1743
- // whole Phase 6 effort exists to drain, just an unmarked instance of it.
1744
- // Composed with the SAME baseline/convention as headerPattern above so
1745
- // target-discovery and next-boundary-discovery agree. The base
1746
- // `#{2,4}\s+` (mandatory whitespace after the hashes, not `\s*`) is
1747
- // preserved exactly as the prior literal had it — a pre-existing,
1748
- // deliberately-unchanged asymmetry with the other sites in this file.
1749
- const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
1750
- // #5599: the boundary is the first following heading OUTSIDE the
1751
- // allocation family's subtree. Taking the first heading of any number
1752
- // spliced the new section above the target's own later decimal children
1753
- // (13, 13.2, 13.1, 14); the allocator already guarantees the new number
1754
- // exceeds every existing descendant, so "after the last descendant" is
1755
- // the numerically correct position.
1756
- const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
1757
- let insertIdx = rawContent.length;
1758
- for (const m of afterHeader.matchAll(nextPhaseRe)) {
1759
- if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
1760
- insertIdx = headerIdx + headerMatch[0].length + m.index;
1761
- break;
2487
+ const headerIdx = searchStart;
2488
+ let insertIdx = bracketHeading?.insertAt ?? searchEnd;
2489
+ if (!bracketHeading) {
2490
+ const afterHeader = rawContent.slice(headerIdx + headerLength, searchEnd);
2491
+ const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
2492
+ // #5599: legacy insertion lands after the allocation family's entire
2493
+ // subtree; bracket insertion keeps its fence-aware, milestone-scoped boundary.
2494
+ const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
2495
+ for (const m of afterHeader.matchAll(nextPhaseRe)) {
2496
+ if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
2497
+ insertIdx = headerIdx + headerLength + m.index;
2498
+ break;
2499
+ }
1762
2500
  }
1763
2501
  }
1764
- updatedContent =
1765
- rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
2502
+ ({ content: updatedContent, entry: roadmapEntry } = recordNewPhase(rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx), cwd, _decimalPhase, description, afterPhase, bracketId ? renderPhaseId(bracketId) : undefined));
2503
+ }
2504
+ // #4304: every validation above — the pre-flight heading
2505
+ // check, the header/bullet-line search, and computing `updatedContent`
2506
+ // — must succeed (or `error()` out, which never returns) before the new
2507
+ // phase's directory is created. A failing insert now leaves `.planning`
2508
+ // byte-identical.
2509
+ if (bracketId) {
2510
+ assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
1766
2511
  }
1767
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
1768
- return { decimalPhase: _decimalPhase, dirName: _dirName };
2512
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
2513
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
2514
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent, bracketContext ? { preserveFencedMarkdownStructure: true } : undefined);
2515
+ return { decimalPhase: _decimalPhase, dirName: _dirName, roadmapEntry };
1769
2516
  });
1770
2517
  const result = {
1771
2518
  phase_number: decimalPhase,
@@ -1773,6 +2520,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1773
2520
  name: description,
1774
2521
  slug,
1775
2522
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
2523
+ roadmap_entry: roadmapEntry,
1776
2524
  };
1777
2525
  output(result, raw, decimalPhase);
1778
2526
  // #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
@@ -1781,12 +2529,28 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1781
2529
  // target bullet/header) exits via `error()`, which terminates the process.
1782
2530
  publishStateContract(cwd);
1783
2531
  }
1784
- function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1785
- const renamedDirs = [];
1786
- const renamedFiles = [];
2532
+ /** Refuse every ambiguous owner before deletion or the first sibling rename. */
2533
+ function assertUnambiguousCustomDirectories(dirs, context) {
2534
+ for (const dir of dirs) {
2535
+ const candidates = phaseIdMod.customDirectoryCandidates(dir, context);
2536
+ if (candidates.length > 1) {
2537
+ error(`Ambiguous custom phase directory ${dir}; candidates: ${candidates.join(', ')}. Refusing removal or rename without ${CUSTOM_PHASE_ID_FILE}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, ERROR_REASON.AMBIGUOUS_PHASE_OWNER, { candidates, marker_path: toPosixPath(node_path_1.default.join(dir, CUSTOM_PHASE_ID_FILE)) });
2538
+ }
2539
+ }
2540
+ }
2541
+ /**
2542
+ * Directories a numeric removal renumbers. The renamers and the `phase remove`
2543
+ * preflight both read this one selector, so the set the preflight refuses on is
2544
+ * the set the renamers would touch. A directory a record or a unique known id
2545
+ * owns is a stable custom identity and is never renumbered; an ambiguous
2546
+ * markerless one stays selected so the caller refuses before any write.
2547
+ */
2548
+ function renameEligible(dirs, context) {
2549
+ return dirs.filter(dir => phaseIdMod.customDirectoryCandidates(dir, context).length !== 1);
2550
+ }
2551
+ function selectDecimalRenames(dirs, baseInt, removedDecimal, context) {
1787
2552
  const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
1788
- const dirs = readSubdirectories(phasesDir, true);
1789
- const toRename = dirs
2553
+ return renameEligible(dirs, context)
1790
2554
  .map((dir) => {
1791
2555
  const m = dir.match(decPattern);
1792
2556
  return m
@@ -1795,14 +2559,41 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
1795
2559
  })
1796
2560
  .filter((item) => item !== null && item.oldDecimal > removedDecimal)
1797
2561
  .sort((a, b) => b.oldDecimal - a.oldDecimal);
1798
- for (const item of toRename) {
1799
- const newDecimal = item.oldDecimal - 1;
1800
- const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
1801
- const newPhaseId = `${baseInt}.${newDecimal}`;
1802
- const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
1803
- (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
1804
- renamedDirs.push({ from: item.dir, to: newDirName });
1805
- for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
2562
+ }
2563
+ function selectIntegerRenames(dirs, removedInt, context) {
2564
+ return renameEligible(dirs, context)
2565
+ .map((dir) => {
2566
+ const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
2567
+ if (!m)
2568
+ return null;
2569
+ const dirInt = parseInt(m[1], 10);
2570
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2571
+ return dirInt > removedInt && !isSentinelPhaseId(dirInt)
2572
+ ? {
2573
+ dir,
2574
+ oldInt: dirInt,
2575
+ letter: m[2] ? m[2].toUpperCase() : '',
2576
+ decimal: m[3] ? parseInt(m[3], 10) : null,
2577
+ slug: m[4],
2578
+ }
2579
+ : null;
2580
+ })
2581
+ .filter((item) => item !== null)
2582
+ .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
2583
+ }
2584
+ function renameDecimalPhases(phasesDir, baseInt, removedDecimal, context) {
2585
+ const renamedDirs = [];
2586
+ const renamedFiles = [];
2587
+ const toRename = selectDecimalRenames(readSubdirectories(phasesDir, true), baseInt, removedDecimal, context);
2588
+ assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
2589
+ for (const item of toRename) {
2590
+ const newDecimal = item.oldDecimal - 1;
2591
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
2592
+ const newPhaseId = `${baseInt}.${newDecimal}`;
2593
+ const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
2594
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
2595
+ renamedDirs.push({ from: item.dir, to: newDirName });
2596
+ for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
1806
2597
  if (f.includes(oldPhaseId)) {
1807
2598
  const newFileName = f.replace(oldPhaseId, newPhaseId);
1808
2599
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
@@ -1835,30 +2626,12 @@ function findOrphanedDisplacementName(dir, fileName) {
1835
2626
  }
1836
2627
  return null;
1837
2628
  }
1838
- function renameIntegerPhases(phasesDir, removedInt) {
2629
+ function renameIntegerPhases(phasesDir, removedInt, context) {
1839
2630
  const renamedDirs = [];
1840
2631
  const renamedFiles = [];
1841
2632
  const renamedFileCollisions = [];
1842
- const dirs = readSubdirectories(phasesDir, true);
1843
- const toRename = dirs
1844
- .map((dir) => {
1845
- const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
1846
- if (!m)
1847
- return null;
1848
- const dirInt = parseInt(m[1], 10);
1849
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1850
- return dirInt > removedInt && !isSentinelPhaseId(dirInt)
1851
- ? {
1852
- dir,
1853
- oldInt: dirInt,
1854
- letter: m[2] ? m[2].toUpperCase() : '',
1855
- decimal: m[3] ? parseInt(m[3], 10) : null,
1856
- slug: m[4],
1857
- }
1858
- : null;
1859
- })
1860
- .filter((item) => item !== null)
1861
- .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
2633
+ const toRename = selectIntegerRenames(readSubdirectories(phasesDir, true), removedInt, context);
2634
+ assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
1862
2635
  for (const item of toRename) {
1863
2636
  const newInt = item.oldInt - 1;
1864
2637
  const newPadded = String(newInt).padStart(2, '0');
@@ -2000,7 +2773,7 @@ function findDataRowLine(sectionText, dataRowIndex) {
2000
2773
  // the caller report accurately, the same fix #3685 applied to
2001
2774
  // `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
2002
2775
  // sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
2003
- function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
2776
+ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd, customTarget, stableIds) {
2004
2777
  return withPlanningLock(cwd, () => {
2005
2778
  const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
2006
2779
  let content = originalContent;
@@ -2012,7 +2785,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2012
2785
  // makes the token padding-insensitive in both directions without widening
2013
2786
  // to other ids: the token stays anchored between `Phase\s+`/line-start and
2014
2787
  // `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
2015
- const padTolerant = `0*${escaped}`;
2788
+ const padTolerant = customTarget ? escaped : `0*${escaped}`;
2789
+ const stableIdentity = (id) => stableIds.has((isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase());
2016
2790
  // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
2017
2791
  // detail section INCLUDING its own heading line. Migrated onto deleteSection
2018
2792
  // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
@@ -2026,7 +2800,11 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2026
2800
  // its tracking table.
2027
2801
  const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
2028
2802
  content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
2029
- content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
2803
+ content = content.replace(
2804
+ // The entry prefix is the phase-entry grammar's (#5764): line-anchored, so a
2805
+ // mid-line mention of the phase is not deleted. `decorated` keeps the tolerance this
2806
+ // delete always had for an entry the readers skip (`- [x] ~~**Phase 2: X**~~`, an emoji).
2807
+ new RegExp(`\\n?${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gim'), '');
2030
2808
  // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
2031
2809
  // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
2032
2810
  // of updateTableCell). Scoped to the `## Progress` section — mirroring
@@ -2042,229 +2820,1636 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2042
2820
  // `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
2043
2821
  // no whitespace to match (#2245 audit) — and which was also unscoped to
2044
2822
  // any particular table.
2045
- const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
2046
- if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
2047
- const headingOffset = progressHeadingMatch.index;
2048
- const before = content.slice(0, headingOffset);
2049
- const fromHeading = content.slice(headingOffset);
2050
- const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
2051
- const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
2052
- const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
2053
- const matchRemovedProgressRow = (row) => {
2054
- const firstCellRaw = (Object.values(row)[0] ?? '').trim();
2055
- if (isDecimal) {
2056
- return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2057
- }
2058
- const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
2059
- if (!leadingMatch || leadingMatch[2])
2060
- return false;
2061
- return parseInt(leadingMatch[1], 10) === removedInt;
2062
- };
2823
+ // #5762: the slice is the Progress owner's (`editRoadmapProgress`), the write side of the
2824
+ // rule every reader uses; `headingless: 'skip'` keeps the pre-owner "no `## Progress`
2825
+ // heading, no row delete" (a row delete must not guess a table by column names alone).
2826
+ const matchRemovedProgressRow = (row) => {
2827
+ const firstCellRaw = (Object.values(row)[0] ?? '').trim();
2828
+ if (customTarget) {
2829
+ return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2830
+ }
2831
+ if (isDecimal) {
2832
+ return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
2833
+ }
2834
+ const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
2835
+ if (!leadingMatch || leadingMatch[2])
2836
+ return false;
2837
+ return parseInt(leadingMatch[1], 10) === removedInt;
2838
+ };
2839
+ content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2063
2840
  const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
2064
- if (deleteResult.ok) {
2065
- content = before + deleteResult.value + rest;
2841
+ return deleteResult.ok ? deleteResult.value : progressSection;
2842
+ }, { headingless: 'skip' });
2843
+ if (!isDecimal && !customTarget) {
2844
+ // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
2845
+ // is re-emitted verbatim — a tagged later phase still gets renumbered.
2846
+ // #5007 (Phase 6 / ADR-4910 §8): composes phaseHeadingPrefixSrcFor
2847
+ // directly (needs prefix/number/suffix as three separate capture groups
2848
+ // for a verbatim in-place rewrite — neither shared owner returns that
2849
+ // shape). Two deliberate departures from the design doc's suggested
2850
+ // composition, both kept for zero behavior change on this
2851
+ // highest-blast-radius site (feeds phase remove's renumber path):
2852
+ // 1. The `#{2,4}\s*` heading marker is kept explicit and folded INTO
2853
+ // the prefix capture group — the design's snippet omitted it
2854
+ // entirely (phaseHeadingPrefixSrcFor never includes it; every
2855
+ // caller adds it, e.g. buildPhaseHeadingScanRegex), and the
2856
+ // original group 1 here captured the marker too.
2857
+ // 2. No convention argument is threaded (LABEL_ONLY with
2858
+ // convention=undefined), even though phaseHeadingPrefixSrcFor
2859
+ // COULD accept one — this keeps the composed source byte-identical
2860
+ // to the prior `Phase\s+` literal, a deliberate non-widening for
2861
+ // this specific site (unlike cmdPhaseInsert's targetPattern).
2862
+ // The number group widens from `\d+(?:\.\d+)?` to the full
2863
+ // PHASE_NUMBER_TOKEN_SOURCE (letter suffix + multi-segment decimals) —
2864
+ // this is SAFE, not a behavior change: decrementRoadmapPhaseToken's own
2865
+ // regex (`^(\d+)(\.\d+)?$`) rejects anything wider and returns the
2866
+ // token unchanged, so a heading this widening newly matches (e.g.
2867
+ // "Phase 12A:") is written back byte-for-byte unchanged, identical to
2868
+ // the pre-migration outcome of the whole regex simply not matching it.
2869
+ content = content.replace(new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)})(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE}\\s*:)`, 'gi'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
2870
+ content = content.replace(
2871
+ // `decorated`, as the delete above: a struck-through or emoji entry is renumbered too,
2872
+ // or two entries would share one number after the remove.
2873
+ new RegExp(`(${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+)(\\d+)([ \\t]*:|[ \\t]+|(?=\\r?$))`, 'gim'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
2874
+ // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
2875
+ // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
2876
+ // directly above). The prior whole-document regex
2877
+ // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
2878
+ // file — including a same-shaped cell in an UNRELATED, earlier table
2879
+ // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
2880
+ // preceding `## Progress`; #2245-class scoping defect, same family as
2881
+ // the row-delete fix above). Scoped here to the `## Progress` section
2882
+ // only, mirroring that same section-slice-then-splice-back pattern.
2883
+ //
2884
+ // Loops because updateTableCell only rewrites the FIRST matching row
2885
+ // per call. `processedOrdinalRows` tracks by row INDEX (stable across
2886
+ // iterations — this only edits cell content, it never inserts/deletes
2887
+ // rows) so an already-decremented row's new value — which may still
2888
+ // numerically exceed `removedInt` — is never re-selected and
2889
+ // decremented a second time (matching on the row's CURRENT value alone,
2890
+ // without this guard, would keep re-firing on each pass).
2891
+ //
2892
+ // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
2893
+ // required: a decimal sub-phase ordinal like `2.5` (no whitespace
2894
+ // between the dot and the next character) never matches it, so it is
2895
+ // left untouched — identical decimal-safety to the prior behaviour.
2896
+ //
2897
+ // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
2898
+ // only, so the row's original leading/trailing alignment padding is
2899
+ // recovered by a narrow, anchored lookup within that row's OWN raw
2900
+ // line — addressed by ROW INDEX (`matchedRowIndex`, via
2901
+ // `findDataRowLine`), not by searching the whole section for content
2902
+ // matching the trimmed value (F8 #2245 review: two rows with identical
2903
+ // trimmed Phase text, or a row whose already-rewritten new value
2904
+ // coincides with another row's pre-edit text, would otherwise resolve
2905
+ // to the WRONG row's padding — the first/leftmost content match found).
2906
+ // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
2907
+ // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
2908
+ // escaped form, so searching for the unescaped `current` would
2909
+ // silently fail to find an escaped-pipe cell's own line — preserving
2910
+ // every other byte of the row (ADR-2143 §7 byte-parity) while only the
2911
+ // digits actually change.
2912
+ // #5762: the slice is the Progress owner's; no `## Progress` heading means no renumber.
2913
+ content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2914
+ let ordinalSection = progressSection;
2915
+ const phaseCellShapeRe = /^(\d+)(\.\s)/;
2916
+ const processedOrdinalRows = new Set();
2917
+ let matchedRowIndex = null;
2918
+ for (;;) {
2919
+ matchedRowIndex = null;
2920
+ const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2921
+ if (processedOrdinalRows.has(index))
2922
+ return false;
2923
+ const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
2924
+ if (!m || stableIdentity(m[1]))
2925
+ return false;
2926
+ const num = parseInt(m[1], 10);
2927
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2928
+ if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
2929
+ return false;
2930
+ processedOrdinalRows.add(index);
2931
+ matchedRowIndex = index;
2932
+ return true;
2933
+ }, 'Phase', (current) => {
2934
+ const m = phaseCellShapeRe.exec(current);
2935
+ if (!m)
2936
+ return current;
2937
+ const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
2938
+ const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2939
+ const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2940
+ const padMatch = targetLine
2941
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2942
+ : null;
2943
+ const leadPad = padMatch ? padMatch[1] : ' ';
2944
+ const trailPad = padMatch ? padMatch[2] : ' ';
2945
+ return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2946
+ });
2947
+ if (!cellResult.ok)
2948
+ break;
2949
+ ordinalSection = cellResult.value;
2950
+ }
2951
+ return ordinalSection;
2952
+ }, { headingless: 'skip' });
2953
+ content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${stableIdentity(phaseNum) ? phaseNum : decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
2954
+ // #5007 (Phase 6 / ADR-4910): migrated the "Depends on" bold-field
2955
+ // decrement off two hand-rolled regexes — one per bold-placement
2956
+ // variant, `**Depends on**: Phase N` and `**Depends on:** Phase N` —
2957
+ // onto the PlanningDoc `boldField` read/write seam (`writePlansField`
2958
+ // above is the established precedent for this migration shape).
2959
+ // `BOLD_FIELD_RE` (planning-document.cts) recognizes BOTH placements
2960
+ // as the SAME field label ("Depends on") — `parseBoldFieldLine` strips
2961
+ // the token down to its inner text regardless of which side of `**`
2962
+ // the colon lands on — so this single loop naturally replaces both
2963
+ // regexes with one call site; no variant-specific branching remains.
2964
+ // Unlike `writePlansField`'s single `findField` lookup (scoped to one
2965
+ // already-isolated phase section), a whole ROADMAP.md can carry a
2966
+ // "Depends on" field in EVERY phase's detail section, so this walks
2967
+ // every `boldField` node in the whole parsed document whose label is
2968
+ // "Depends on" and rewrites each one independently — a
2969
+ // representability refusal or absent match on one field leaves that
2970
+ // field untouched and never blocks any other field, mirroring the
2971
+ // prior regexes' per-occurrence independence. A parse failure or an
2972
+ // unreadable-nodes refusal on `serialize` leaves `content` byte-for-
2973
+ // byte untouched, the same silent no-op the prior regexes had for any
2974
+ // input they didn't match (neither ever threw or warned).
2975
+ const dependsOnParsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'ROADMAP.md');
2976
+ if (dependsOnParsed.ok) {
2977
+ let dependsOnDoc = dependsOnParsed.value;
2978
+ const dependsOnFieldIds = dependsOnDoc.nodes
2979
+ .filter((n) => n.kind === 'boldField' && n.label === 'Depends on')
2980
+ .map((n) => n.id);
2981
+ for (const fieldId of dependsOnFieldIds) {
2982
+ const currentRead = (0, planning_document_cjs_1.readNode)(dependsOnDoc, fieldId);
2983
+ if (!currentRead.ok)
2984
+ continue;
2985
+ const depMatch = /^(Phase\s+)(\d+(?:\.\d+)?)\b/i.exec(currentRead.value);
2986
+ if (!depMatch)
2987
+ continue;
2988
+ const [wholeMatch, prefix, num] = depMatch;
2989
+ const newValue = `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}` + currentRead.value.slice(wholeMatch.length);
2990
+ if (newValue === currentRead.value)
2991
+ continue;
2992
+ const staged = (0, planning_document_cjs_1.setFieldValue)(dependsOnDoc, fieldId, newValue);
2993
+ if (staged.ok)
2994
+ dependsOnDoc = staged.value;
2995
+ }
2996
+ const dependsOnOut = (0, planning_document_cjs_1.serialize)(dependsOnDoc);
2997
+ if (dependsOnOut.ok)
2998
+ content = dependsOnOut.value;
2999
+ }
3000
+ }
3001
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
3002
+ // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
3003
+ // persists), not the raw pre-normalize `content` string, against the raw
3004
+ // pre-mutation `originalContent` read above — a raw `!==` here reports a
3005
+ // false `true` whenever this transform's regenerated output takes a
3006
+ // different-but-equivalent shape than the already-normalized on-disk
3007
+ // original (same normalization-order artifact #3685 fixed at
3008
+ // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
3009
+ return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
3010
+ });
3011
+ }
3012
+ /**
3013
+ * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
3014
+ * the leading frontmatter block's closing `---` fence — so a body field never
3015
+ * lands before the opening fence. The former whole-content prepend
3016
+ * (`field + content`) put the line ABOVE the opening `---`, and
3017
+ * syncStateFrontmatter then treated the scrambled fence structure as TWO
3018
+ * frontmatter blocks, rebuilding a derived one on top of the original
3019
+ * (milestone_name from a ROADMAP heading, total_phases counting the removed
3020
+ * phase, a stray 'Total Phases: 0' between fences). A file with no leading
3021
+ * frontmatter is all body: the field goes to content start, preserving the
3022
+ * former behavior for that shape.
3023
+ *
3024
+ * The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
3025
+ * and every STATE.md reader agree on where the body starts. The blank line and the field go
3026
+ * right after the closing fence line, ended by that line's own line ending — so a CRLF file
3027
+ * gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
3028
+ * return; joining the new lines on a bare '\n' mixed line endings).
3029
+ */
3030
+ function insertStateBodyFieldAtTop(content, fieldLine) {
3031
+ const fence = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(content);
3032
+ if (fence?.closed) {
3033
+ const nl = content[fence.closingFenceEnd] === '\r' ? '\r\n' : '\n';
3034
+ return `${content.slice(0, fence.closingFenceEnd)}${nl}${nl}${fieldLine}${content.slice(fence.closingFenceEnd)}`;
3035
+ }
3036
+ // All body: the field is the new first line, ended like the document's first line.
3037
+ const firstNewline = content.indexOf('\n');
3038
+ return fieldLine + (firstNewline > 0 && content[firstNewline - 1] === '\r' ? '\r\n' : '\n') + content;
3039
+ }
3040
+ function renameBracketArtifactFiles(phaseDir, oldId, newId, renamedFiles, renamedFileCollisions) {
3041
+ const oldToken = bracketArtifactToken(oldId);
3042
+ const newToken = bracketArtifactToken(newId);
3043
+ for (const file of node_fs_1.default.readdirSync(phaseDir)) {
3044
+ if (!file.startsWith(oldToken) || !/^(?:[-.]|$)/.test(file.slice(oldToken.length)))
3045
+ continue;
3046
+ const newFileName = newToken + file.slice(oldToken.length);
3047
+ const source = node_path_1.default.join(phaseDir, file);
3048
+ const destination = node_path_1.default.join(phaseDir, newFileName);
3049
+ if (node_fs_1.default.existsSync(destination)) {
3050
+ const displaced = findOrphanedDisplacementName(phaseDir, newFileName);
3051
+ if (!displaced) {
3052
+ renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: null });
3053
+ continue;
2066
3054
  }
3055
+ (0, shell_command_projection_cjs_1.retryRenameSync)(destination, node_path_1.default.join(phaseDir, displaced));
3056
+ renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: displaced });
3057
+ }
3058
+ (0, shell_command_projection_cjs_1.retryRenameSync)(source, destination);
3059
+ renamedFiles.push({ from: file, to: newFileName });
3060
+ }
3061
+ }
3062
+ /**
3063
+ * #4304: the ONE identity mapping shared by the disk rename
3064
+ * (renameBracketPhases, below) and the ROADMAP rewrite
3065
+ * (updateRoadmapAfterBracketPhaseRemoval) — disk and ROADMAP can never
3066
+ * disagree because both consume this same computation instead of each
3067
+ * deriving its own. Identities are collected from the same directory scan
3068
+ * renameBracketPhases uses to find rename candidates, unioned with every
3069
+ * heading/checklist/progress line inside the active milestone's own ranges
3070
+ * (primary + Phase Details): a phase can exist in ROADMAP with no directory
3071
+ * yet, or on disk with no matching ROADMAP line, and either source alone
3072
+ * can miss a decimal sub-phase identity. Both sources are canonical bracket
3073
+ * spellings only; cmdPhaseRemove refuses before any mutation while a legacy
3074
+ * spelling resolves to an identity this mapping deletes, moves, or lands on
3075
+ * (legacySpellingsEntangledWithBracketRemoval). Integer removal maps every phase
3076
+ * N > removed to N-1 (a sub-phase's own number is unchanged); sub-phase
3077
+ * removal maps every sub-phase S > removed within the target phase to S-1.
3078
+ * The result is sorted ascending by phase, then subphase (a bare phase before
3079
+ * its own sub-phases), so applying every entry to the same line in sequence
3080
+ * never re-matches a value an earlier entry just wrote: each entry's new
3081
+ * value is one below its old value, and every entry that could still hold
3082
+ * that value as its old value has already run. Bare and sub-phase entries
3083
+ * are independent of each other because the rewriters never match a bare
3084
+ * id inside a longer sub-phase id (`03` inside `03.01`).
3085
+ */
3086
+ function computeBracketRenumberMapping(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase) {
3087
+ const identities = new Map();
3088
+ const record = (phase, subphase) => {
3089
+ identities.set(`${phase}.${subphase ?? ''}`, { phase, subphase });
3090
+ };
3091
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
3092
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3093
+ }
3094
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3095
+ const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
3096
+ for (const line of splitRoadmapLineRecords(roadmapContent)) {
3097
+ if (!lineStartsInActiveMilestone(line.start, ranges))
3098
+ continue;
3099
+ if (historicalLineStarts.has(line.start) || fencedLineNumbers.has(line.lineNumber))
3100
+ continue;
3101
+ const { id } = classifyBracketOwnedLine(line.text);
3102
+ if (!id || id.project !== context.project || id.milestone !== context.milestone)
3103
+ continue;
3104
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3105
+ }
3106
+ const filtered = [...identities.values()].filter(({ phase, subphase }) => {
3107
+ if (removedSubphase !== undefined) {
3108
+ return phase === removedInt && subphase !== undefined && subphase > removedSubphase;
3109
+ }
3110
+ return phase > removedInt && !isSentinelPhaseId(phase);
3111
+ });
3112
+ filtered.sort((a, b) => (a.phase - b.phase) || ((a.subphase ?? 0) - (b.subphase ?? 0)));
3113
+ return filtered.map(({ phase, subphase }) => ({
3114
+ oldId: bracketPhaseId(context, phase, subphase),
3115
+ newId: removedSubphase !== undefined
3116
+ ? bracketPhaseId(context, phase, subphase - 1)
3117
+ : bracketPhaseId(context, phase - 1, subphase),
3118
+ }));
3119
+ }
3120
+ /**
3121
+ * CommonMark fence exclusion shared by bracket removal's inventory and
3122
+ * pre-mutation ownership guard. A fenced heading/checklist is documentation,
3123
+ * never evidence about the live phase tree. Unclosed fences own through EOF.
3124
+ */
3125
+ function fencedRoadmapLineNumbers(content) {
3126
+ const fenced = new Set();
3127
+ const rawLines = content.split('\n');
3128
+ for (const block of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)) {
3129
+ const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
3130
+ for (let index = block.openLineIdx; index <= lastIndex; index++) {
3131
+ fenced.add(index + 1);
3132
+ }
3133
+ }
3134
+ return fenced;
3135
+ }
3136
+ /**
3137
+ * #4304: single fence-aware details-container tracker for bracket writers.
3138
+ * Every real tag carries its nesting depth before and after the tag, and every
3139
+ * matched block spans its own opening through the corresponding close. A
3140
+ * nested close therefore cannot terminate its enclosing block. Unclosed
3141
+ * blocks still contribute depth to the tag stream so deletion stays bounded.
3142
+ */
3143
+ function trackRoadmapDetails(content) {
3144
+ const lines = splitRoadmapLineRecords(content);
3145
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3146
+ const tags = [];
3147
+ const blocks = [];
3148
+ const stack = [];
3149
+ const detailsTagRe = /<\/?details\b[^>]*>/gi;
3150
+ let lineIndex = 0;
3151
+ let match;
3152
+ while ((match = detailsTagRe.exec(content)) !== null) {
3153
+ while (lineIndex + 1 < lines.length && lines[lineIndex + 1].start <= match.index)
3154
+ lineIndex += 1;
3155
+ const line = lines[lineIndex];
3156
+ if (!line || fencedLineNumbers.has(line.lineNumber))
3157
+ continue;
3158
+ const closing = /^<\/details\b/i.test(match[0]);
3159
+ const depthBefore = stack.length;
3160
+ let opening;
3161
+ if (closing)
3162
+ opening = stack.pop();
3163
+ const tag = {
3164
+ offset: match.index,
3165
+ end: match.index + match[0].length,
3166
+ lineStart: line.start,
3167
+ closing,
3168
+ depthBefore,
3169
+ depthAfter: stack.length + (closing ? 0 : 1),
3170
+ };
3171
+ if (!closing)
3172
+ stack.push(tag);
3173
+ tags.push(tag);
3174
+ if (opening) {
3175
+ blocks.push({
3176
+ start: opening.offset,
3177
+ end: tag.end,
3178
+ startLine: opening.lineStart,
3179
+ endLine: tag.lineStart,
3180
+ text: content.slice(opening.offset, tag.end),
3181
+ });
3182
+ }
3183
+ }
3184
+ return { tags, blocks };
3185
+ }
3186
+ /**
3187
+ * #4304: does the bracket phase about to be removed (an
3188
+ * INTEGER phase, never a subphase itself — `phase remove NN.SS` is a
3189
+ * different, unaffected path) still have its own sub-phases?
3190
+ * `computeBracketRenumberMapping`'s own filter only ever maps
3191
+ * `phase > removedInt` — the removed phase's OWN sub-phases (`phase ===
3192
+ * removedInt`) are never mapped, deleted, or reported — so removing an
3193
+ * integer phase that still has sub-phases left them orphaned on disk and
3194
+ * in ROADMAP while the NEXT phase's sub-phases renumbered onto the SAME
3195
+ * identities, manufacturing duplicates. Scans the two sources
3196
+ * computeBracketRenumberMapping unions (the canonical directory scan, and
3197
+ * every heading/checklist/progress line outside CommonMark fences and inside
3198
+ * the active milestone's own ranges), plus the legacy-spelled child
3199
+ * directories the active reader resolves, which the mapping never
3200
+ * inventories. A child spelled either way refuses here; a legacy spelling of
3201
+ * any other identity the removal moves is refused by
3202
+ * legacySpellingsEntangledWithBracketRemoval.
3203
+ */
3204
+ function bracketPhaseOwnSubphases(phasesDir, roadmapContent, ranges, context, targetInt) {
3205
+ const bySubphase = new Map();
3206
+ const record = (phase, subphase) => {
3207
+ if (phase === targetInt && subphase !== undefined) {
3208
+ bySubphase.set(subphase, bracketPhaseId(context, phase, subphase));
3209
+ }
3210
+ };
3211
+ for (const { id } of bracketIdsInContext(phasesDir, context)) {
3212
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3213
+ }
3214
+ // #4304: bracketIdsInContext skips names outside the
3215
+ // canonical bracket grammar, but the reader also resolves migration-window
3216
+ // legacy children such as `CK-02.1-child`, and the allocator already
3217
+ // reserves them (readerResolvableBracketDirectoryPhaseNumber). Inventory
3218
+ // that same universe the same way: take each directory's own key and keep
3219
+ // a sub-phase of the target only when the active bracket reader resolves
3220
+ // that directory for that key. `record` dedupes canonical children.
3221
+ for (const dir of readSubdirectories(phasesDir, true)) {
3222
+ const [phasePart, subphasePart, ...rest] = phaseKeyFromDir(dir, 'bracket').split('.');
3223
+ if (rest.length > 0 || subphasePart === undefined)
3224
+ continue;
3225
+ if (!/^\d+$/.test(phasePart) || !/^\d+$/.test(subphasePart))
3226
+ continue;
3227
+ if (Number(phasePart) !== targetInt)
3228
+ continue;
3229
+ const key = `${phasePart}.${subphasePart}`;
3230
+ if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
3231
+ continue;
3232
+ record(targetInt, Number(subphasePart));
3233
+ }
3234
+ const roadmapLines = splitRoadmapLineRecords(roadmapContent);
3235
+ const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
3236
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3237
+ for (const line of roadmapLines) {
3238
+ if (!lineStartsInActiveMilestone(line.start, ranges))
3239
+ continue;
3240
+ if (fencedLineNumbers.has(line.lineNumber) || historicalLineStarts.has(line.start))
3241
+ continue;
3242
+ const { id } = classifyBracketOwnedLine(line.text);
3243
+ if (!id || id.project !== context.project || id.milestone !== context.milestone)
3244
+ continue;
3245
+ record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
3246
+ }
3247
+ return [...bySubphase.keys()].sort((a, b) => a - b).map((s) => bySubphase.get(s));
3248
+ }
3249
+ /**
3250
+ * #4304: identify ROADMAP lines owned by historical milestone sections.
3251
+ * A `<details>` block is historical only when the roadmap reader's own
3252
+ * closed-summary classifier says it is; an active collapsed phase list stays
3253
+ * live. Outside details, a reader-recognized CLOSED/ARCHIVED/SHIPPED milestone
3254
+ * heading owns the section through the next reader-recognized milestone
3255
+ * heading at the same or shallower level. Headings come from tokenizeHeadings,
3256
+ * so fenced examples are neither historical markers nor section resets.
3257
+ * Recognition is imported from the window locator's milestone-vs-phase
3258
+ * grammar before the marker predicate is applied, so an ordinary phase title
3259
+ * containing FAILED or ✅ never opens or resets a historical section.
3260
+ *
3261
+ * Details membership comes from the shared fence-aware depth tracker, so every
3262
+ * line remains archived while any enclosing details block is a closed
3263
+ * milestone archive. This classifier is consumed by both the active-window
3264
+ * safety guard and bracket removal's rewrite pass. Historical lines are
3265
+ * evidence of neither an active-window mismatch nor a reference that removal
3266
+ * may rewrite or delete.
3267
+ */
3268
+ function archivedOrClosedMilestoneLineStarts(content) {
3269
+ const historical = new Set();
3270
+ const headingsByOffset = new Map((0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).map((heading) => [heading.offset, heading]));
3271
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3272
+ const lines = splitRoadmapLineRecords(content);
3273
+ const details = trackRoadmapDetails(content);
3274
+ const historicalDetailsLineStarts = new Set();
3275
+ for (const block of details.blocks) {
3276
+ if (!isClosedMilestoneDetails(block.text))
3277
+ continue;
3278
+ for (const line of lines) {
3279
+ if (line.start >= block.startLine && line.start <= block.endLine) {
3280
+ historicalDetailsLineStarts.add(line.start);
3281
+ }
3282
+ }
3283
+ }
3284
+ const detailsTagsByLine = new Map();
3285
+ for (const tag of details.tags) {
3286
+ const lineTags = detailsTagsByLine.get(tag.lineStart) ?? [];
3287
+ lineTags.push(tag);
3288
+ detailsTagsByLine.set(tag.lineStart, lineTags);
3289
+ }
3290
+ let detailsDepth = 0;
3291
+ let closedHeadingLevel = 0;
3292
+ for (const line of lines) {
3293
+ const fenced = fencedLineNumbers.has(line.lineNumber);
3294
+ const lineTags = detailsTagsByLine.get(line.start) ?? [];
3295
+ const insideDetails = detailsDepth > 0 || lineTags.some((tag) => !tag.closing);
3296
+ if (historicalDetailsLineStarts.has(line.start) || closedHeadingLevel > 0)
3297
+ historical.add(line.start);
3298
+ if (lineTags.length > 0)
3299
+ detailsDepth = lineTags[lineTags.length - 1].depthAfter;
3300
+ if (insideDetails) {
3301
+ continue;
3302
+ }
3303
+ if (fenced) {
3304
+ continue;
3305
+ }
3306
+ const heading = headingsByOffset.get(line.start);
3307
+ if (heading) {
3308
+ const level = heading.level;
3309
+ const milestoneHeading = isRecognizedMilestoneHeading(heading.text, level);
3310
+ if (milestoneHeading && closedHeadingLevel && level <= closedHeadingLevel)
3311
+ closedHeadingLevel = 0;
3312
+ if (!closedHeadingLevel && milestoneHeading && isClosedMilestoneHeading(heading.text)) {
3313
+ closedHeadingLevel = level;
3314
+ }
3315
+ }
3316
+ if (closedHeadingLevel > 0)
3317
+ historical.add(line.start);
3318
+ }
3319
+ return historical;
3320
+ }
3321
+ /**
3322
+ * #4304: a pre-mutation SAFETY CHECK, not a fix to the read
3323
+ * side's own window selection (mislocating the active window is PR-6 /
3324
+ * #4751 territory — this function does not touch that). When the read side
3325
+ * mislocates the active milestone window — a non-closed heading carrying
3326
+ * the active version token sits BEFORE the real milestone heading ("##
3327
+ * Goals for v2.0"), a document-level Progress heading placed before the
3328
+ * milestone heading, or no milestone heading exists at all — the checklist/
3329
+ * heading rewrite loop in `updateRoadmapAfterBracketPhaseRemoval` never
3330
+ * reaches the target's REAL heading/checklist lines, because they sit
3331
+ * outside `ranges`. Nothing downstream of that silently-empty rewrite stops
3332
+ * the destructive directory delete/rename that already ran by the time the
3333
+ * ROADMAP rewrite would have reported the mismatch, so the command "succeeds"
3334
+ * with an empty report while the target's heading and checklist survive
3335
+ * beside the sibling renumbered onto its old identity.
3336
+ *
3337
+ * Scans the WHOLE document outside CommonMark fences (not `ranges` — the
3338
+ * located window is exactly
3339
+ * what is in question) for the target's own heading/checklist lines via the
3340
+ * SAME classifier (`classifyBracketOwnedLine`) the rewrite loop uses. If at
3341
+ * least one is found and NONE of them fall inside the located `ranges`
3342
+ * (primary or details) — including when `ranges` is null, i.e. no milestone
3343
+ * window was located at all — the target is misplaced relative to what the
3344
+ * read side thinks is active, and the caller must refuse before touching
3345
+ * disk. A target correctly inside `ranges` (the overwhelmingly common case)
3346
+ * returns null immediately; this never widens or narrows behavior for a
3347
+ * correctly-located window.
3348
+ */
3349
+ function bracketOwnedLineOutsideActiveWindow(content, targetId, ranges) {
3350
+ // Heading deletion (`deleteSection`, scoped to `preDeleteRanges`) and
3351
+ // checklist-row deletion (the per-line loop, gated on `active`) are two
3352
+ // INDEPENDENT scoped operations in `updateRoadmapAfterBracketPhaseRemoval`
3353
+ // — a degenerate window can legitimately contain one kind of owned line
3354
+ // while missing the other (with no milestone heading, the version-less
3355
+ // bracket fallback selects the first PHASE heading as if it were the milestone heading, so
3356
+ // the resulting window happens to span every later phase HEADING to EOF
3357
+ // while the checklist bullets — which sit ABOVE that heading — are still
3358
+ // entirely outside it). Finding the heading safely inside must never mask
3359
+ // a checklist row that is not, or vice versa: track each kind separately.
3360
+ let headingInside = false;
3361
+ let checklistInside = false;
3362
+ let firstOutsideHeading = null;
3363
+ let firstOutsideChecklist = null;
3364
+ // #4304: a line archived inside <details> or sitting under a CLOSED milestone
3365
+ // heading (isClosedMilestoneHeading — the SAME predicate
3366
+ // currentMilestoneRawRanges itself uses to skip a closed heading when
3367
+ // selecting the active one) is a SHIPPED milestone's own line — never
3368
+ // evidence the ACTIVE window is mislocated. Same-code point releases
3369
+ // (milestoneToken folds v2.0/v2.1 to one bracket id) legitimately carry
3370
+ // the same [CODE.MM] NN identity in both the shipped archive/section and
3371
+ // the active phase's heading-only entry (exactly what `phase add` writes,
3372
+ // with no checklist bullet of its own) — this never widens what counts as
3373
+ // OUTSIDE, only narrows which OUTSIDE lines count as evidence.
3374
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
3375
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
3376
+ for (const line of splitRoadmapLineRecords(content)) {
3377
+ if (fencedLineNumbers.has(line.lineNumber))
3378
+ continue;
3379
+ const owned = classifyBracketOwnedLine(line.text);
3380
+ if (!owned.id || (owned.kind !== 'heading' && owned.kind !== 'checklist'))
3381
+ continue;
3382
+ if (!sameBracketPhaseId(owned.id, targetId))
3383
+ continue;
3384
+ const insideActive = Boolean(ranges
3385
+ && ((line.start >= ranges.primary.start && line.start < ranges.primary.end)
3386
+ || (ranges.details !== null
3387
+ && line.start >= ranges.details.start && line.start < ranges.details.end)));
3388
+ if (historicalLineStarts.has(line.start))
3389
+ continue;
3390
+ if (owned.kind === 'heading') {
3391
+ if (insideActive)
3392
+ headingInside = true;
3393
+ else if (!firstOutsideHeading)
3394
+ firstOutsideHeading = { lineNumber: line.lineNumber, text: line.text.trim() };
3395
+ }
3396
+ else {
3397
+ if (insideActive)
3398
+ checklistInside = true;
3399
+ else if (!firstOutsideChecklist)
3400
+ firstOutsideChecklist = { lineNumber: line.lineNumber, text: line.text.trim() };
3401
+ }
3402
+ }
3403
+ const outside = firstOutsideHeading && !headingInside ? firstOutsideHeading
3404
+ : firstOutsideChecklist && !checklistInside ? firstOutsideChecklist : null;
3405
+ return { outside, hasLiveTarget: headingInside || checklistInside };
3406
+ }
3407
+ function bracketRenameCandidates(phasesDir, context, mapping) {
3408
+ const mappingKey = (phase, subphase) => `${Number(phase)}.${subphase === undefined ? '' : Number(subphase)}`;
3409
+ const byKey = new Map();
3410
+ for (const { oldId, newId } of mapping)
3411
+ byKey.set(mappingKey(oldId.phase, oldId.subphase), newId);
3412
+ return bracketIdsInContext(phasesDir, context)
3413
+ .filter(({ id }) => byKey.has(mappingKey(id.phase, id.subphase)))
3414
+ .sort((a, b) => {
3415
+ const phaseDelta = Number(a.id.phase) - Number(b.id.phase);
3416
+ return phaseDelta !== 0
3417
+ ? phaseDelta
3418
+ : Number(a.id.subphase ?? 0) - Number(b.id.subphase ?? 0);
3419
+ })
3420
+ .map((item) => {
3421
+ const newId = byKey.get(mappingKey(item.id.phase, item.id.subphase));
3422
+ return { item, newId, newDirName: toDir(newId, item.slug) };
3423
+ });
3424
+ }
3425
+ function assertBracketRenameDestinationsSafe(phasesDir, context, mapping, targetDir) {
3426
+ const occupied = new Set(readSubdirectories(phasesDir));
3427
+ if (targetDir !== null)
3428
+ occupied.delete(targetDir);
3429
+ for (const { item, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
3430
+ // Simulate the same ascending sequence the rename loop will apply. A
3431
+ // destination is free only after its target or earlier source vacates it.
3432
+ assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into', occupied);
3433
+ occupied.delete(item.dir);
3434
+ occupied.add(newDirName);
3435
+ }
3436
+ }
3437
+ function renameBracketPhases(phasesDir, context, mapping) {
3438
+ const renamedDirs = [];
3439
+ const renamedFiles = [];
3440
+ const renamedFileCollisions = [];
3441
+ for (const { item, newId, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
3442
+ // Recheck immediately before the rename as well as cmdPhaseRemove's
3443
+ // pre-mutation pass, closing the validation-to-use gap within this loop.
3444
+ assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into');
3445
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
3446
+ renamedDirs.push({ from: item.dir, to: newDirName });
3447
+ renameBracketArtifactFiles(node_path_1.default.join(phasesDir, newDirName), item.id, newId, renamedFiles, renamedFileCollisions);
3448
+ }
3449
+ return { renamedDirs, renamedFiles, renamedFileCollisions };
3450
+ }
3451
+ const BRACKET_OWNED_PHASE_INTRO_SRC = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true);
3452
+ const BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC = `(${PHASE_NUMBER_TOKEN_SOURCE})`;
3453
+ const BRACKET_OWNED_TAG_SRC = '(?:[ \\t]*\\([^)\\r\\n]{0,200}\\))?';
3454
+ // #4304: every reader compiles BRACKET_OWNED_PHASE_INTRO_SRC's
3455
+ // own source (phaseHeadingPrefixSrcFor's bracket alternative) with the `i`
3456
+ // flag — `BRACKET_PROJECT_CODE_SRC` is deliberately spelled `[A-Z]...` on the
3457
+ // understanding that recognition folds case at compile time, never in the
3458
+ // source. The owned-line expressions below compile it with that same flag;
3459
+ // compiled with no flags at all, `[ck.02] 02:`,
3460
+ // `[CK.02] phase 02:` and `[CK.02] PHASE 02:` (case variants the read
3461
+ // grammar and `phase insert`/`phase add` accept) would
3462
+ // never classify as owned lines here. Headings and progress cells still compile
3463
+ // this source locally; checklist rows route through parsePhaseChecklistLine,
3464
+ // the same semantic reader used by init manager.
3465
+ const BRACKET_HEADING_LINE_RE = new RegExp(`^ {0,3}#{2,4}[ \\t]*${BRACKET_OWNED_PHASE_INTRO_SRC}`
3466
+ + `${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}${BRACKET_OWNED_TAG_SRC}[ \\t]*:`, 'i');
3467
+ const BRACKET_CELL_ID_RE = new RegExp(`^${BRACKET_OWNED_PHASE_INTRO_SRC}${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}`
3468
+ + `${BRACKET_OWNED_TAG_SRC}(?:[ \\t]*:|[ \\t]|$)`, 'i');
3469
+ function splitRoadmapLineRecords(content) {
3470
+ const records = [];
3471
+ const lineRe = /([^\r\n]*)(\r\n|\n|$)/g;
3472
+ let match;
3473
+ let lineNumber = 1;
3474
+ while ((match = lineRe.exec(content)) !== null) {
3475
+ if (match[0] === '')
3476
+ break;
3477
+ records.push({
3478
+ text: match[1],
3479
+ eol: match[2],
3480
+ start: match.index,
3481
+ lineNumber,
3482
+ });
3483
+ lineNumber += 1;
3484
+ }
3485
+ return records;
3486
+ }
3487
+ function phaseIdFromOwnedParts(bracketId, phaseNumber) {
3488
+ if (!bracketId || !phaseNumber)
3489
+ return null;
3490
+ try {
3491
+ // #4304: parsePhaseId's own display-form regex requires an
3492
+ // uppercase project code and checks canonicality by requiring the
3493
+ // re-rendered id to be byte-equal to the input, so a lowercase capture
3494
+ // from the case-insensitive owned-line regexes above (`[ck.02]
3495
+ // 02:`) would throw here and classify as "not owned", the
3496
+ // identity-recognition gap `foldBracketId`'s own doc comment in
3497
+ // phase-id.cts warns about ("fold before any identity operation; never
3498
+ // fold for display"). This is an identity operation.
3499
+ //
3500
+ // #4304: the captured NUMBER needs the exact same
3501
+ // treatment. BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC is deliberately
3502
+ // TOLERANT (PHASE_NUMBER_TOKEN_SOURCE, the read side's own grammar) so it
3503
+ // captures "2", "002", and "02.1" — the same non-canonical spellings
3504
+ // `roadmap get-phase`/`analyze`/`validate`, `phase insert` and
3505
+ // `phase add` treat as real phases, but parsePhaseId's
3506
+ // canonicality check (render(parse(x)) === x) throws on every one of
3507
+ // them, so without this step the line would classify as 'other' and its
3508
+ // owning heading/checklist/progress row would never be recognized.
3509
+ // Canonicalize through the SAME adapter the bare-token argument path
3510
+ // uses (phase-id-display's `phaseToken`, per-segment pad2) BEFORE
3511
+ // parsePhaseId, exactly as `canonicalizeBracketPhaseArgument` already
3512
+ // does for a CLI argument. A token phaseToken cannot canonicalize (a
3513
+ // legacy M-NN letter suffix) falls through unchanged, which still fails
3514
+ // parsePhaseId's own grammar precisely as before — this only widens
3515
+ // acceptance to spellings phaseToken itself accepts.
3516
+ const canonicalNumber = phaseToken(phaseNumber) ?? phaseNumber;
3517
+ return parsePhaseId(`[${foldBracketId(bracketId)}] ${canonicalNumber}`);
3518
+ }
3519
+ catch {
3520
+ return null;
3521
+ }
3522
+ }
3523
+ function phaseIdFromOwnedLineMatch(match) {
3524
+ return phaseIdFromOwnedParts(match?.[1], match?.[2]);
3525
+ }
3526
+ function classifyBracketOwnedLine(line) {
3527
+ const headingId = phaseIdFromOwnedLineMatch(BRACKET_HEADING_LINE_RE.exec(line));
3528
+ if (headingId)
3529
+ return { kind: 'heading', id: headingId };
3530
+ const checklist = parsePhaseChecklistLine(line, 'bracket');
3531
+ const checklistId = phaseIdFromOwnedParts(checklist?.bracketId, checklist?.phaseToken);
3532
+ if (checklistId)
3533
+ return { kind: 'checklist', id: checklistId };
3534
+ if (/^[ \t]*\|/.test(line)) {
3535
+ const firstCell = (0, markdown_table_cjs_1.splitTableRow)(line)[0]?.replace(/^\*\*(.*)\*\*$/, '$1') ?? '';
3536
+ const progressId = phaseIdFromOwnedLineMatch(BRACKET_CELL_ID_RE.exec(firstCell));
3537
+ if (progressId)
3538
+ return { kind: 'progress', id: progressId };
3539
+ }
3540
+ return { kind: 'other', id: null };
3541
+ }
3542
+ function sameBracketPhaseId(a, b) {
3543
+ return a.project === b.project
3544
+ && a.milestone === b.milestone
3545
+ && a.phase === b.phase
3546
+ && a.subphase === b.subphase;
3547
+ }
3548
+ /**
3549
+ * A bracket removal may share the live tree with migration-window legacy
3550
+ * spellings, but it must never mutate that legacy identity indirectly by
3551
+ * renumbering later bracket phases onto it. This check covers a target that
3552
+ * resolves only to legacy spellings; legacySpellingsEntangledWithBracketRemoval
3553
+ * covers a legacy spelling of any other identity the removal moves. Resolve both
3554
+ * directory and heading evidence before any write. `matchPhaseDirs` owns legacy directory selection;
3555
+ * `BRACKET_HEADING_LINE_RE` owns the reader-tolerant heading grammar and its
3556
+ * legacy `Phase N:` alternative. A real, non-historical bracket heading for the
3557
+ * target makes this an ordinary bracket removal even when legacy siblings are
3558
+ * also present.
3559
+ */
3560
+ function legacyOnlyBracketRemovalTarget(subdirs, roadmapContent, targetPhase, targetId, hasBracketDirectory) {
3561
+ const legacyNormalized = normalizePhaseName(targetPhase);
3562
+ const legacyDirectories = matchPhaseDirs(subdirs, legacyNormalized).matches.filter((dir) => {
3563
+ try {
3564
+ parsePhaseId(dir);
3565
+ return false;
3566
+ }
3567
+ catch {
3568
+ return true;
3569
+ }
3570
+ });
3571
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3572
+ const legacyHeadings = [];
3573
+ let hasBracketHeading = false;
3574
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
3575
+ if (heading.level < 2 || heading.level > 4)
3576
+ continue;
3577
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3578
+ const owned = classifyBracketOwnedLine(headingLine);
3579
+ if (owned.kind === 'heading' && owned.id) {
3580
+ if (!historicalLineStarts.has(heading.offset) && sameBracketPhaseId(owned.id, targetId)) {
3581
+ hasBracketHeading = true;
3582
+ }
3583
+ continue;
3584
+ }
3585
+ // The shared bracket selector includes the legacy `Phase N:` alternative.
3586
+ // If classification did not yield a bracket id, capture that alternative's
3587
+ // numeric token and compare it through the same writer canonicalizer.
3588
+ const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3589
+ const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
3590
+ const targetNumber = targetId.subphase ? `${targetId.phase}.${targetId.subphase}` : targetId.phase;
3591
+ if (canonicalNumber === targetNumber)
3592
+ legacyHeadings.push(headingLine);
3593
+ }
3594
+ if (hasBracketDirectory || hasBracketHeading)
3595
+ return null;
3596
+ if (legacyDirectories.length === 0 && legacyHeadings.length === 0)
3597
+ return null;
3598
+ return { directories: legacyDirectories, headings: legacyHeadings };
3599
+ }
3600
+ /**
3601
+ * #4304: legacy spellings the active bracket reader resolves to an identity
3602
+ * this removal deletes, renumbers, or renumbers onto. The removal renames only
3603
+ * canonical bracket directories and rewrites only bracket-owned ROADMAP lines
3604
+ * (the inventory computeBracketRenumberMapping unions), so such a spelling
3605
+ * would keep its old number: orphaned, or read as whichever phase lands on
3606
+ * that number. Integer removal moves whole phases, so a spelling counts when
3607
+ * its phase number is the target's or any mapped phase's, old or new, which
3608
+ * also covers a legacy sub-phase under a moving parent. Sub-phase removal
3609
+ * compares full identities. A legacy phase outside that set keeps a number
3610
+ * nothing claims after the removal, so it does not block it.
3611
+ *
3612
+ * Directories are inventoried the way the allocator and the removal guard for
3613
+ * the target's own sub-phases do: each non-canonical directory's
3614
+ * `phaseKeyFromDir` key, kept only when `matchPhaseDirs` resolves the directory
3615
+ * for that key. Headings use legacyOnlyBracketRemovalTarget's recognition,
3616
+ * limited to the active milestone's own non-historical lines.
3617
+ */
3618
+ function legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase, mapping) {
3619
+ const identityKey = (phase, subphase) => `${phase}.${subphase ?? ''}`;
3620
+ const movingPhases = new Set([removedInt]);
3621
+ const movingIdentities = new Set([identityKey(removedInt, removedSubphase)]);
3622
+ for (const { oldId, newId } of mapping) {
3623
+ for (const id of [oldId, newId]) {
3624
+ const subphase = id.subphase === undefined ? undefined : Number(id.subphase);
3625
+ movingPhases.add(Number(id.phase));
3626
+ movingIdentities.add(identityKey(Number(id.phase), subphase));
3627
+ }
3628
+ }
3629
+ const entangled = (phase, subphase) => (removedSubphase === undefined
3630
+ ? movingPhases.has(phase)
3631
+ : subphase !== undefined && movingIdentities.has(identityKey(phase, subphase)));
3632
+ const numericIdentity = (token) => {
3633
+ const [phasePart, subphasePart, ...rest] = token.split('.');
3634
+ if (rest.length > 0 || !/^\d+$/.test(phasePart))
3635
+ return null;
3636
+ if (subphasePart !== undefined && !/^\d+$/.test(subphasePart))
3637
+ return null;
3638
+ const phase = Number(phasePart);
3639
+ if (isSentinelPhaseId(phase))
3640
+ return null;
3641
+ return subphasePart === undefined ? { phase } : { phase, subphase: Number(subphasePart) };
3642
+ };
3643
+ const found = [];
3644
+ for (const dir of readSubdirectories(phasesDir, true)) {
3645
+ try {
3646
+ parsePhaseId(dir);
3647
+ continue;
3648
+ }
3649
+ catch {
3650
+ // Not canonical bracket grammar: a migration-window legacy spelling.
3651
+ }
3652
+ const key = phaseKeyFromDir(dir, 'bracket');
3653
+ const identity = numericIdentity(key);
3654
+ if (!identity || !entangled(identity.phase, identity.subphase))
3655
+ continue;
3656
+ if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
3657
+ continue;
3658
+ found.push({ kind: 'directory', spelling: dir, id: bracketPhaseId(context, identity.phase, identity.subphase) });
3659
+ }
3660
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
3661
+ for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
3662
+ if (heading.level < 2 || heading.level > 4)
3663
+ continue;
3664
+ if (!lineStartsInActiveMilestone(heading.offset, ranges) || historicalLineStarts.has(heading.offset))
3665
+ continue;
3666
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3667
+ if (classifyBracketOwnedLine(headingLine).id)
3668
+ continue;
3669
+ const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3670
+ const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
3671
+ const identity = canonicalNumber ? numericIdentity(canonicalNumber) : null;
3672
+ if (!identity || !entangled(identity.phase, identity.subphase))
3673
+ continue;
3674
+ found.push({ kind: 'heading', spelling: headingLine, id: bracketPhaseId(context, identity.phase, identity.subphase) });
3675
+ }
3676
+ return found;
3677
+ }
3678
+ /** Name a legacy spelling, the bracket identity it resolves to, and its bracket form. */
3679
+ function describeEntangledLegacySpelling({ kind, spelling, id }) {
3680
+ return kind === 'directory'
3681
+ ? `directory ${JSON.stringify(spelling)} is ${renderPhaseId(id)} (bracket prefix ${JSON.stringify(bracketDirPrefix(id))})`
3682
+ : `heading ${JSON.stringify(spelling)} is ${renderPhaseId(id)}`;
3683
+ }
3684
+ /**
3685
+ * Classify a fence-excluded heading token through the roadmap reader's shared
3686
+ * bracket-plus-legacy phase-entry predicate, then decide whether it names a
3687
+ * phase other than the selected removal target. Bracket headings retain their
3688
+ * full project+milestone identity; a bare numeric `Phase NN:` heading is
3689
+ * compared in the active target context. A reader-recognized custom legacy id
3690
+ * cannot equal a numeric bracket target and is therefore distinct.
3691
+ */
3692
+ function isDistinctReaderPhaseHeading(heading, targetId) {
3693
+ if (heading.level < 2 || heading.level > 4)
3694
+ return false;
3695
+ if (!isPhaseEntryHeading(heading.text, 'bracket'))
3696
+ return false;
3697
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
3698
+ const owned = classifyBracketOwnedLine(headingLine);
3699
+ if (owned.kind === 'heading' && owned.id)
3700
+ return !sameBracketPhaseId(owned.id, targetId);
3701
+ const numericMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
3702
+ const canonicalNumber = numericMatch?.[2] ? phaseToken(numericMatch[2]) : null;
3703
+ if (!canonicalNumber)
3704
+ return true;
3705
+ const [phase, subphase] = canonicalNumber.split('.');
3706
+ return phase !== targetId.phase || subphase !== targetId.subphase;
3707
+ }
3708
+ function dashBracketPhaseId(id) {
3709
+ return `${id.project}.${id.milestone}-${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
3710
+ }
3711
+ /** The `PP[.SS][-LL]` tail of a rendered bracket phase id — renderPhaseId minus its milestone-bracket prefix. */
3712
+ function bracketPhaseNumberSrc(id) {
3713
+ return renderPhaseId(id).slice(renderMilestoneId(id).length + 1);
3714
+ }
3715
+ function replaceQualifiedBracketReference(line, oldId, newId) {
3716
+ const boundary = 'A-Za-z0-9.-';
3717
+ const newNumber = bracketPhaseNumberSrc(newId);
3718
+ const oldDisplay = renderPhaseId(oldId);
3719
+ const qualified = tokenizePhaseDependencyReferences(line, 'bracket')
3720
+ .filter((token) => {
3721
+ if (token.kind !== 'qualified' || token.token !== oldDisplay)
3722
+ return false;
3723
+ const before = token.referenceStart === undefined ? '' : line[token.referenceStart - 1] ?? '';
3724
+ const after = line[token.end] ?? '';
3725
+ return !new RegExp(`[${boundary}]`).test(before) && !new RegExp(`[${boundary}]`).test(after);
3726
+ })
3727
+ .sort((a, b) => b.start - a.start);
3728
+ let rewritten = line;
3729
+ for (const token of qualified) {
3730
+ rewritten = rewritten.slice(0, token.start) + newNumber + rewritten.slice(token.end);
3731
+ }
3732
+ const oldDash = dashBracketPhaseId(oldId);
3733
+ const newDash = dashBracketPhaseId(newId);
3734
+ return rewritten.replace(new RegExp(`(?<![${boundary}])${(0, pattern_cjs_1.escapeRegex)(oldDash)}(?![${boundary}])`, 'g'), () => newDash);
3735
+ }
3736
+ function replaceBareBracketArtifactReference(line, oldId, newId) {
3737
+ const oldToken = bracketArtifactToken(oldId);
3738
+ const newToken = bracketArtifactToken(newId);
3739
+ const filename = `${(0, pattern_cjs_1.escapeRegex)(oldToken)}-\\d{2}`
3740
+ + '(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md';
3741
+ return line.replace(new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`, 'g'), (match) => newToken + match.slice(oldToken.length));
3742
+ }
3743
+ function lineStartsInActiveMilestone(lineStart, ranges) {
3744
+ if (!ranges)
3745
+ return false;
3746
+ return (lineStart >= ranges.primary.start && lineStart < ranges.primary.end)
3747
+ || Boolean(ranges.details && lineStart >= ranges.details.start && lineStart < ranges.details.end);
3748
+ }
3749
+ /**
3750
+ * #4304: a NARROWER boundary than currentMilestoneRawRanges'
3751
+ * own primary/details ranges. Those stop only at a RECOGNIZED bracket/
3752
+ * version-bearing milestone heading, by design (the read path's own
3753
+ * milestone-scanning use case treats an unrelated heading — a
3754
+ * `## Requirements Traceability`, a `## Notes` — as staying inside the
3755
+ * current milestone's content, not as a boundary). A pipe-table row has no
3756
+ * "this is my milestone's own table" marker beyond ordinary heading
3757
+ * nesting, so deciding whether a progress/table row belongs to THIS
3758
+ * milestone needs the plain Markdown rule instead: stop at the next
3759
+ * heading of any kind at or above the milestone heading's own level —
3760
+ * the SAME rule deleteSection/collectSection already apply elsewhere.
3761
+ */
3762
+ function bracketMilestoneOwnTableEnd(content, sectionStart, headings) {
3763
+ const level = (content.slice(sectionStart).match(/^#{1,4}/) ?? ['#'])[0].length;
3764
+ for (const h of headings) {
3765
+ if (h.offset <= sectionStart)
3766
+ continue;
3767
+ if (h.level <= level)
3768
+ return h.offset;
3769
+ }
3770
+ return content.length;
3771
+ }
3772
+ /**
3773
+ * #4304: does `lineStart` fall inside the active milestone's
3774
+ * OWN progress/table content — its primary or details section, bounded by
3775
+ * `bracketMilestoneOwnTableEnd` rather than the wider currentMilestoneRawRanges
3776
+ * end?
3777
+ */
3778
+ function lineStartsInMilestoneOwnTable(content, lineStart, ranges, headings) {
3779
+ if (!ranges)
3780
+ return false;
3781
+ if (lineStart >= ranges.primary.start
3782
+ && lineStart < bracketMilestoneOwnTableEnd(content, ranges.primary.start, headings)) {
3783
+ return true;
3784
+ }
3785
+ return Boolean(ranges.details
3786
+ && lineStart >= ranges.details.start
3787
+ && lineStart < bracketMilestoneOwnTableEnd(content, ranges.details.start, headings));
3788
+ }
3789
+ // #4304: the ONE textual expression of "this heading is
3790
+ // titled Progress" — the title starts with the word "Progress", any
3791
+ // suffix admitted ("Progress", "Progress (v2.1)", "Progress — current"),
3792
+ // case-insensitive. `bracketProgressSectionRange` below already tolerated a
3793
+ // suffix at its own fixed level-2 anchor; `isProgressHeading` inside
3794
+ // `bracketOwnProgressSectionRanges` required an EXACT "progress" match, so
3795
+ // a suffixed OWN heading ("## Progress (v2.1)", "### Progress (v2.0)")
3796
+ // never engaged the own-section scope even though the document-first scope
3797
+ // already matched the same suffix. Both interpolate this one source now.
3798
+ // #5762: that one source now lives in the Roadmap Progress Module (`isProgressHeadingTitle`);
3799
+ // `bracketProgressSectionRange` below is `findProgressSection` minus archived milestones.
3800
+ /**
3801
+ * #4304: the SAME `## Progress`-section scope legacy's
3802
+ * updateRoadmapAfterPhaseRemoval already uses (#2012, src/phase.cts:2482-2500)
3803
+ * — the first `## Progress` heading (case-insensitive) through the next
3804
+ * `#`/`##` heading or EOF. Lets a DOCUMENT-LEVEL Progress table that sits
3805
+ * textually outside the active milestone's own ranges (e.g. after a later
3806
+ * milestone's own section, the existing "global Progress table" shape) stay
3807
+ * in scope for the target row's own deletion, exactly as it already was.
3808
+ */
3809
+ function bracketProgressSectionRange(content) {
3810
+ // #5762: the Progress owner's section (first level-2 Progress heading to the next level-1/2
3811
+ // heading), minus the archived/closed milestone headings this remover must not touch.
3812
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
3813
+ return (0, roadmap_progress_cjs_1.findProgressSection)(content, { exclude: (offset) => historicalLineStarts.has(offset) });
3814
+ }
3815
+ /**
3816
+ * #4304: every distinct RECOGNIZED milestone
3817
+ * heading in the document, one representative offset per VERSION (one
3818
+ * carrying a version token — `listMilestoneHeadings`' own grammar, via the
3819
+ * SAME selection rule, `selectMilestoneHeading`, `currentMilestoneRawRanges`
3820
+ * already uses to pick "the" heading for a single version) PLUS one marker
3821
+ * per version-LESS bracket milestone heading recognized by the window
3822
+ * locator's own grammar (`isBracketMilestoneBoundary`,
3823
+ * src/roadmap-parser.cts — the SAME predicate `bracketAwareMilestoneSection`
3824
+ * uses to decide a candidate heading is a milestone boundary while walking
3825
+ * to find where the ACTIVE milestone's own section ends).
3826
+ *
3827
+ * A version-less ADR-612 canonical milestone heading
3828
+ * (`## [CK.02] Shipped ✅`, no `vX.Y` anywhere) is a marker because the
3829
+ * window locator recognizes it as a milestone boundary when walking the
3830
+ * document. Without it, a same-code shipped/active pair sharing no version
3831
+ * token would leave the shipped milestone's own `## Progress` heading
3832
+ * invisible to the sandwich test, and its own Complete row would be
3833
+ * deleted as if the table were shared/global.
3834
+ *
3835
+ * Deliberately NOT merged into one "one representative per identity" pass:
3836
+ * a shipped and active milestone commonly share the SAME bracket code
3837
+ * (`milestoneToken` folds `v2.0`/`v2.1` alike) while remaining two SEPARATE
3838
+ * heading positions that each open their own section — collapsing them by
3839
+ * id would silently drop one of the two boundaries the sandwich test needs.
3840
+ * Version-token headings keep their existing one-per-version selection
3841
+ * (unaffected, since every such heading already carries the distinguishing
3842
+ * signal); only headings the version grammar cannot see at all (no `vX.Y`
3843
+ * anywhere on the line) fall through to the boundary-predicate scan, so a
3844
+ * heading already represented by the version loop is never double-counted
3845
+ * merely for also being bracket-shaped (its own "(Phase Details)"
3846
+ * continuation heading carries the SAME version token and is excluded here
3847
+ * exactly as it always was — `selectMilestoneHeading` picks only the first
3848
+ * non-closed occurrence of that version, never the continuation).
3849
+ * `content[h.offset] === '#'` mirrors `bracketFallbackHeadingMatches`'s own
3850
+ * ATX-only guard (a `<summary>` line naming a milestone is never a heading).
3851
+ * Sorted ascending. Used to decide which milestone (if any) a document-first
3852
+ * `## Progress` heading sits immediately after, with nothing of its own kind
3853
+ * in between.
3854
+ */
3855
+ function bracketRecognizedMilestoneMarkers(content) {
3856
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
3857
+ const versions = new Set(listMilestoneHeadings(content).map((h) => h.version));
3858
+ const markers = [];
3859
+ for (const version of versions) {
3860
+ const selected = selectMilestoneHeading(content, version);
3861
+ if (selected?.index !== undefined)
3862
+ markers.push(selected.index);
3863
+ }
3864
+ for (const h of headings) {
3865
+ if (h.level > 3 || content[h.offset] !== '#')
3866
+ continue;
3867
+ // Already represented above: this heading carries a version token the
3868
+ // first loop already enumerated (its own group's selected marker, or —
3869
+ // for a same-version continuation like "(Phase Details)" — the OTHER
3870
+ // occurrence `selectMilestoneHeading` picked instead). Mirrors
3871
+ // `extractMilestoneHeadingName`'s own no-`expectedVersion` grammar
3872
+ // (src/roadmap-parser.cts) literally rather than importing a private
3873
+ // helper across the module boundary for one boolean test.
3874
+ if (/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i.test(h.text))
3875
+ continue;
3876
+ // #4304: a "(Phase Details)" heading is always a CONTINUATION of
3877
+ // whatever milestone opened before it (`currentMilestoneRawRanges`'s own
3878
+ // `detailsMatch` grammar, src/roadmap-parser.cts:2288-2294), never a
3879
+ // second, separate milestone marker in its own right — the version loop
3880
+ // above already skips a VERSIONED continuation for exactly this reason
3881
+ // (comment above); a version-LESS one (`## [CK.02] Current (Phase
3882
+ // Details)`) fell through to this boundary-predicate scan uncaught,
3883
+ // because `isBracketMilestoneBoundary` is called with `selectedBracketId`
3884
+ // `null` here and so cannot recognize it as a continuation of its own
3885
+ // milestone.
3886
+ if (/\(Phase\s+Details\)/i.test(h.text))
3887
+ continue;
3888
+ if (!isBracketMilestoneBoundary(h.text, h.level, null))
3889
+ continue;
3890
+ markers.push(h.offset);
3891
+ }
3892
+ return Array.from(new Set(markers)).sort((a, b) => a - b);
3893
+ }
3894
+ /**
3895
+ * #4304: does the DOCUMENT-FIRST
3896
+ * `## Progress` heading `bracketProgressSectionRange` found belong to a
3897
+ * DIFFERENT, non-active milestone's OWN dedicated section, rather than
3898
+ * being a genuinely document-level/shared table?
3899
+ *
3900
+ * The answer "the active milestone has a Progress heading of its own, and
3901
+ * this isn't it, so it must be someone else's" would over-claim: a
3902
+ * genuinely global `## Progress` that lists every milestone's rows (before
3903
+ * any milestone heading at all, or trailing after the LAST recognized
3904
+ * milestone heading with nothing bounding it on the far side) is neither
3905
+ * the active milestone's own nor any OTHER milestone's dedicated section.
3906
+ *
3907
+ * The rule asks a POSITIONAL question instead, over every recognized
3908
+ * milestone heading in the document (`bracketRecognizedMilestoneMarkers`),
3909
+ * not just the active one: is this Progress heading SANDWICHED strictly
3910
+ * between two recognized milestone headings — i.e. does it immediately
3911
+ * follow one specific milestone's own heading (nothing else of that kind in
3912
+ * between) AND does some other recognized milestone heading follow it
3913
+ * later in the document? Only then is it unambiguously that earlier
3914
+ * milestone's own trailing section (a shipped milestone's own table, or the
3915
+ * same-code two-versions shape). A Progress heading with NOTHING preceding it (top of document) or
3916
+ * NOTHING following it (trailing after the last recognized milestone
3917
+ * heading) is open, shared territory — never "elsewhere" — because a
3918
+ * milestone's own dedicated section and a document-wide table that merely
3919
+ * happens to sit after the last milestone's content are textually
3920
+ * indistinguishable by position alone once nothing bounds the far side.
3921
+ */
3922
+ function bracketProgressSectionOwnedByOtherMilestone(content, progressStart, activeRanges, ownProgressSectionRanges) {
3923
+ // Fast path: exactly one of the ACTIVE milestone's own recognized
3924
+ // Progress-titled headings (any level) is never "elsewhere".
3925
+ if (ownProgressSectionRanges.some((r) => r.start === progressStart))
3926
+ return false;
3927
+ // #4304: the positional question below applies only when the ACTIVE
3928
+ // milestone owns a Progress heading of its own; only then can a DIFFERENT
3929
+ // Progress heading be "someone else's". Without that precondition, a
3930
+ // document whose ACTIVE milestone has no dedicated Progress heading of its
3931
+ // own (the common single-shared-table layout: one global `## Progress`
3932
+ // table, no per-milestone one) would have its shared table declared
3933
+ // another milestone's the moment ANY version-bearing heading (a
3934
+ // `## Backlog (v4.0 candidates)` line, a changelog entry) followed it in
3935
+ // the document, because the positional scan alone cannot distinguish "this
3936
+ // table is the NEXT milestone's own dedicated section" from "this table is
3937
+ // shared and a later milestone heading simply comes after it in the file".
3938
+ // With no own Progress heading at all, the active milestone has no OWN claim
3939
+ // any Progress heading could be "instead of", so
3940
+ // a shared table can never be misread as belonging to a different one.
3941
+ if (ownProgressSectionRanges.length === 0)
3942
+ return false;
3943
+ // #4304: drop any marker lying STRICTLY inside the ACTIVE milestone's
3944
+ // own ranges — a heading inside the active's own window (a same-id prose
3945
+ // sub-heading like "### [CK.02] Notes", admitted by the boundary-predicate
3946
+ // scan above because it is bracket-shaped and version-less) never opens a
3947
+ // DIFFERENT milestone's section; it is content the active milestone itself
3948
+ // owns. A marker AT the range's own start (the active's own primary/details
3949
+ // heading) is kept — that is the "sandwiched under the ACTIVE milestone's
3950
+ // own heading" case just below, not an interior heading. Same-code SHIPPED
3951
+ // headings are never inside the active's own ranges, so they stay markers.
3952
+ const markers = bracketRecognizedMilestoneMarkers(content).filter((offset) => {
3953
+ if (!activeRanges)
3954
+ return true;
3955
+ const insidePrimary = offset > activeRanges.primary.start && offset < activeRanges.primary.end;
3956
+ const insideDetails = activeRanges.details !== null
3957
+ && offset > activeRanges.details.start && offset < activeRanges.details.end;
3958
+ return !insidePrimary && !insideDetails;
3959
+ });
3960
+ let precedingIndex = -1;
3961
+ for (let i = 0; i < markers.length; i++) {
3962
+ if (markers[i] <= progressStart)
3963
+ precedingIndex = i;
3964
+ else
3965
+ break;
3966
+ }
3967
+ // Nothing precedes it (top-of-document global table): not owned by anyone.
3968
+ if (precedingIndex === -1)
3969
+ return false;
3970
+ // Sandwiched under the ACTIVE milestone's own heading: not "elsewhere".
3971
+ const precedingOffset = markers[precedingIndex];
3972
+ if (activeRanges && precedingOffset === activeRanges.primary.start)
3973
+ return false;
3974
+ // Owned by a DIFFERENT milestone only when something else recognized
3975
+ // follows it — a trailing section after the LAST recognized milestone
3976
+ // heading is open territory, never exclusively that last milestone's own.
3977
+ return precedingIndex < markers.length - 1;
3978
+ }
3979
+ /**
3980
+ * #4304: the active milestone's OWN heading
3981
+ * titled "Progress" (`isProgressHeadingTitle` — any suffix, any
3982
+ * level, case-insensitive), found ANYWHERE inside its primary or details
3983
+ * ranges — regardless of what precedes it within those ranges.
3984
+ * `bracketMilestoneOwnTableEnd`/`lineStartsInMilestoneOwnTable` above anchor
3985
+ * ONLY at the milestone/details heading itself, so an intervening heading of
3986
+ * level <= the milestone's own (a `## Notes` aside, or the milestone's OWN
3987
+ * `## Progress` heading when a shipped sibling sharing the bracket code
3988
+ * sorts its own `## Progress` first in the document) closed that "own
3989
+ * table" window before ever reaching the table it was
3990
+ * meant to include. This is ADDITIVE, never a replacement for it: the
3991
+ * existing bare-table-directly-after-phase-headings scope still
3992
+ * independently keeps an unrelated `## Requirements Traceability` table
3993
+ * out of its deletion scope, because that heading is not titled "Progress".
3994
+ *
3995
+ * The exact-title check required "progress", so a suffixed
3996
+ * title ("## Progress (v2.1)", "### Progress (v2.0)") never engaged this
3997
+ * scope even though the document-first `bracketProgressSectionRange` scope
3998
+ * already matched the same suffix — a same-code two-versions shipped/active
3999
+ * pair each titling their own table "Progress (vX.Y)" left the ACTIVE
4000
+ * milestone's own table unrecognized as its own.
4001
+ *
4002
+ * A second gap the same title-widening exposes: a LEVEL-2 own Progress
4003
+ * heading whose title EMBEDS the active milestone's own version token
4004
+ * ("## Progress (v2.1)", level 2, same level as the milestone heading
4005
+ * itself) is exactly what `currentMilestoneRawRanges`' own section-end scan
4006
+ * mistakes for a NEW milestone's heading (it stops at any heading matching
4007
+ * `v\d+\.\d+|✅|📋|🚧`, not just a real milestone one) — ending `ranges`
4008
+ * immediately BEFORE this heading, so it never falls inside
4009
+ * `ranges.primary`/`.details` even once titled "Progress". A heading is
4010
+ * never titled merely "Progress" AND is a different milestone's own
4011
+ * section-opening heading at once, so a Progress-titled heading sitting
4012
+ * EXACTLY at the wide range's own end boundary is still the active
4013
+ * milestone's own — the range ended there only because this heading's own
4014
+ * suffix looked like a marker, not because a real, different milestone
4015
+ * heading intervened.
4016
+ */
4017
+ function bracketOwnProgressSectionRanges(content, ranges, headings) {
4018
+ if (!ranges)
4019
+ return [];
4020
+ const isProgressHeading = (h) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(h.text);
4021
+ const withinActive = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
4022
+ || Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end)
4023
+ || offset === ranges.primary.end
4024
+ || Boolean(ranges.details && offset === ranges.details.end);
4025
+ const out = [];
4026
+ for (const h of headings) {
4027
+ if (!isProgressHeading(h) || !withinActive(h.offset))
4028
+ continue;
4029
+ out.push({ start: h.offset, end: bracketMilestoneOwnTableEnd(content, h.offset, headings) });
4030
+ }
4031
+ return out;
4032
+ }
4033
+ // #4304: shared "tolerant" trailing boundary for the
4034
+ // reporting-only detection regexes below. `(?!\d|\.\d)` blocks extending
4035
+ // into more digits or a ".digit" continuation (so a bare identity never
4036
+ // falsely matches inside a longer number or a different decimal sibling),
4037
+ // but — unlike the rewrite functions' own boundary — permits a following
4038
+ // '.', '-', letter, or end of line, so a genuinely stale mention survives
4039
+ // detection even when it sits before sentence-final punctuation or is
4040
+ // embedded in a directory-name suffix the rewriter deliberately declines
4041
+ // to touch (detection must be allowed to find more than the rewriter is
4042
+ // safe to fix).
4043
+ const BRACKET_REPORT_TOLERANT_BOUNDARY_SRC = '(?!\\d|\\.\\d)';
4044
+ function bracketQualifiedMentionedInLine(line, id) {
4045
+ const targetDisplay = renderPhaseId(id);
4046
+ if (tokenizePhaseDependencyReferences(line, 'bracket')
4047
+ .some((token) => token.kind === 'qualified' && token.token === targetDisplay))
4048
+ return true;
4049
+ const dash = dashBracketPhaseId(id);
4050
+ return new RegExp(`(?<![A-Za-z0-9.-])${(0, pattern_cjs_1.escapeRegex)(dash)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`).test(line);
4051
+ }
4052
+ function bracketLegacyPhaseMentionedInLine(line, id) {
4053
+ const token = bracketArtifactToken(id);
4054
+ return new RegExp(
4055
+ // #4304: a bracket-QUALIFIED, labeled mention
4056
+ // ("[CK.02] Phase 03") is handled completely by
4057
+ // replaceQualifiedBracketReference now — it is never "the legacy bare
4058
+ // 'Phase NN' spelling this detector exists for. Without the negative
4059
+ // lookbehind, the token search matched INSIDE that qualified mention
4060
+ // too (nothing distinguished "Phase 03" preceded by "[CK.02] " from a
4061
+ // genuinely unqualified "Phase 03:" heading), so a correctly-renumbered
4062
+ // labeled line was reported as a dangling reference to the OLD id it no
4063
+ // longer contains.
4064
+ `(?<!\\][ \\t]{0,10})\\bPhase[ \\t]+${(0, pattern_cjs_1.escapeRegex)(token)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`, 'i').test(line);
4065
+ }
4066
+ function bracketArtifactMentionedInLine(line, id) {
4067
+ const token = bracketArtifactToken(id);
4068
+ const filename = `${(0, pattern_cjs_1.escapeRegex)(token)}-\\d{2}(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md`;
4069
+ return new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`).test(line);
4070
+ }
4071
+ /**
4072
+ * #4304: computed from the ORIGINAL (pre-rewrite) line, never
4073
+ * the persisted content: re-searching the PERSISTED text for a
4074
+ * pre-renumber id produces false positives whenever two or more phases
4075
+ * shift (a later phase's NEW value collides textually with an earlier
4076
+ * phase's OLD value).
4077
+ *
4078
+ * The removed identity is a dangling reference by construction wherever it
4079
+ * is mentioned — nothing ever rewrites a reference to a deleted phase — so
4080
+ * every spelling is checked directly with the tolerant boundary. A
4081
+ * renumbered (old -> new) identity is "left untouched" only if running the
4082
+ * SAME rewrite this line would actually receive still leaves a mention of
4083
+ * the old identity behind afterward: the qualified-reference and
4084
+ * bare-artifact rewriters have their own, stricter boundaries (e.g. they
4085
+ * decline to rewrite immediately before a sentence-final period, or a
4086
+ * bare token embedded in a directory-path segment), so a mention can be
4087
+ * real and still survive the rewrite. The legacy "Phase NN" spelling is
4088
+ * never rewritten by any bracket rewriter, so it is always checked
4089
+ * directly for a renumbered identity too.
4090
+ */
4091
+ function lineContainsTrackedBracketIdentity(originalLine, removedId, mapping) {
4092
+ if (bracketQualifiedMentionedInLine(originalLine, removedId)
4093
+ || bracketLegacyPhaseMentionedInLine(originalLine, removedId)
4094
+ || bracketArtifactMentionedInLine(originalLine, removedId)) {
4095
+ return true;
4096
+ }
4097
+ for (const { oldId, newId } of mapping) {
4098
+ if (bracketLegacyPhaseMentionedInLine(originalLine, oldId))
4099
+ return true;
4100
+ const afterQualified = replaceQualifiedBracketReference(originalLine, oldId, newId);
4101
+ if (bracketQualifiedMentionedInLine(afterQualified, oldId))
4102
+ return true;
4103
+ const afterArtifact = replaceBareBracketArtifactReference(originalLine, oldId, newId);
4104
+ if (bracketArtifactMentionedInLine(afterArtifact, oldId))
4105
+ return true;
4106
+ }
4107
+ return false;
4108
+ }
4109
+ /**
4110
+ * #4304: maximum deletion boundary for a selected bracket phase heading.
4111
+ * The active milestone range is the outer bound. Inside it, an HTML details
4112
+ * boundary wins sooner. The shared fence-aware depth tracker preserves the
4113
+ * closing tag of the exact container that encloses the target, or the opening
4114
+ * tag of a container that starts after an outside target. Nested details do
4115
+ * not count until their own close has restored the target's original depth.
4116
+ * The deletion extent also stops at the next distinct phase heading recognized
4117
+ * by the reader, whatever its heading depth; ordinary deeper subheadings remain
4118
+ * part of the target section.
4119
+ */
4120
+ function bracketPhaseDeletionContainerBoundary(content, targetOffset) {
4121
+ const tags = trackRoadmapDetails(content).tags;
4122
+ let targetDepth = 0;
4123
+ for (const tag of tags) {
4124
+ if (tag.offset >= targetOffset)
4125
+ break;
4126
+ targetDepth = tag.depthAfter;
4127
+ }
4128
+ for (const tag of tags) {
4129
+ if (tag.offset < targetOffset)
4130
+ continue;
4131
+ if (targetDepth === 0 && !tag.closing)
4132
+ return tag.lineStart;
4133
+ if (targetDepth > 0 && tag.closing && tag.depthBefore === targetDepth)
4134
+ return tag.lineStart;
4135
+ }
4136
+ return null;
4137
+ }
4138
+ /**
4139
+ * #4304: the pure ROADMAP rewrite for a bracket removal, computed from the
4140
+ * content alone so `phase remove` can run it before any mutation and refuse
4141
+ * on what it would leave behind. `survivingTargetRows` lists every live
4142
+ * (unfenced, not historical) pipe-table row keyed by the removed identity
4143
+ * that the rewrite keeps: a shared Progress table the scope rules cannot
4144
+ * attribute, or a non-Progress table such as a phase-keyed traceability
4145
+ * table. Keeping such a row leaves a dangling identity that the renumbered
4146
+ * sibling then duplicates; deleting it could drop data the row carries.
4147
+ */
4148
+ function planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd) {
4149
+ // #4304: scope the section deletion to the active
4150
+ // milestone's own ranges (the SAME primary+details discovery the
4151
+ // checklist-row deletion below already uses), computed from the
4152
+ // content BEFORE deletion. Without this, deleteSection removes the
4153
+ // FIRST matching heading in the whole document: a shipped milestone
4154
+ // and the active one sharing the same bracket code (milestoneToken
4155
+ // folds e.g. v2.0 and v2.1 to one [CK.02]) let the shipped section's
4156
+ // own detail heading be deleted while the active one survives.
4157
+ const preDeleteRanges = currentMilestoneRawRanges(originalContent, cwd, 'bracket');
4158
+ // #4304: historical protection applies while SELECTING the section, not
4159
+ // only during the later per-line rewrite. A closed details archive can
4160
+ // sit inside the raw active-milestone range and carry the same folded
4161
+ // bracket id as the live phase; choosing that heading first deletes
4162
+ // history and leaves the live target behind.
4163
+ const preDeleteHistoricalLineStarts = archivedOrClosedMilestoneLineStarts(originalContent);
4164
+ const isTargetHeading = (heading) => {
4165
+ // #4304: classify the heading through the SAME shared
4166
+ // owned-line grammar (classifyBracketOwnedLine / BRACKET_HEADING_LINE_RE)
4167
+ // the checklist/progress-row deletion below already uses, instead of
4168
+ // a literal `startsWith(targetDisplay)`; that comparison only ever
4169
+ // recognized the display spelling ("[CK.02] 02"), so the read-grammar-
4170
+ // admitted labeled spelling ("[CK.02] Phase 02:", pinned at
4171
+ // tests/adr-612-bracket-grammar.test.cjs:644) was never matched here
4172
+ // and its detail section survived a "removal" that deleted every
4173
+ // other owned line for the same identity.
4174
+ if (heading.level < 2 || heading.level > 4)
4175
+ return false;
4176
+ const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
4177
+ const owned = classifyBracketOwnedLine(headingLine);
4178
+ if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId)) {
4179
+ return false;
4180
+ }
4181
+ if (preDeleteHistoricalLineStarts.has(heading.offset))
4182
+ return false;
4183
+ if (!preDeleteRanges)
4184
+ return true;
4185
+ return ((heading.offset >= preDeleteRanges.primary.start && heading.offset < preDeleteRanges.primary.end)
4186
+ || Boolean(preDeleteRanges.details
4187
+ && heading.offset >= preDeleteRanges.details.start
4188
+ && heading.offset < preDeleteRanges.details.end));
4189
+ };
4190
+ const selectedTargetHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find(isTargetHeading);
4191
+ let deletionEndOffset;
4192
+ if (selectedTargetHeading) {
4193
+ const nextDistinctPhaseHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find((heading) => heading.offset > selectedTargetHeading.offset
4194
+ && isDistinctReaderPhaseHeading(heading, targetId));
4195
+ const containingRange = preDeleteRanges
4196
+ ? [preDeleteRanges.primary, ...(preDeleteRanges.details ? [preDeleteRanges.details] : [])]
4197
+ .find((range) => selectedTargetHeading.offset >= range.start && selectedTargetHeading.offset < range.end)
4198
+ : null;
4199
+ const containerBoundary = bracketPhaseDeletionContainerBoundary(originalContent, selectedTargetHeading.offset);
4200
+ const historicalBoundary = [...preDeleteHistoricalLineStarts]
4201
+ .filter((offset) => offset > selectedTargetHeading.offset)
4202
+ .sort((a, b) => a - b)[0];
4203
+ deletionEndOffset = Math.min(containingRange?.end ?? originalContent.length, containerBoundary ?? originalContent.length, historicalBoundary ?? originalContent.length, nextDistinctPhaseHeading?.offset ?? originalContent.length);
4204
+ }
4205
+ let content = (0, markdown_sectionizer_cjs_1.deleteSection)(originalContent, isTargetHeading, { endOffset: deletionEndOffset });
4206
+ let roadmapLinesRewritten = content === originalContent ? 0 : 1;
4207
+ const ranges = currentMilestoneRawRanges(content, cwd, 'bracket');
4208
+ // #4304: progress/table-row deletion is
4209
+ // scoped to the active milestone's OWN table content
4210
+ // (bracketMilestoneOwnTableEnd, narrower than `ranges` itself, see its
4211
+ // own doc comment) plus its own "Progress"-titled heading found ANYWHERE
4212
+ // in its ranges (bracketOwnProgressSectionRanges, additive:
4213
+ // covers a `## Notes` aside or a per-milestone `## Progress` the plain
4214
+ // own-table-end closes over too early) plus a document-level
4215
+ // `## Progress` section that is not itself owned by a DIFFERENT
4216
+ // milestone (legacy's own #2012 scope, plus an ownership gate),
4217
+ // never the whole document, so a same-identity row in another pipe table
4218
+ // (a shipped milestone sharing the same bracket code, an unrelated
4219
+ // Requirements Traceability table) is never deleted.
4220
+ const headingsForOwnTable = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
4221
+ const progressSectionRange = bracketProgressSectionRange(content);
4222
+ const ownProgressSectionRanges = bracketOwnProgressSectionRanges(content, ranges, headingsForOwnTable);
4223
+ const progressSectionOwnedElsewhere = progressSectionRange
4224
+ ? bracketProgressSectionOwnedByOtherMilestone(content, progressSectionRange.start, ranges, ownProgressSectionRanges)
4225
+ : false;
4226
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
4227
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
4228
+ // A row inside a Progress-titled section that the ownership rule assigns
4229
+ // to a different milestone is that milestone's own record: kept, and not
4230
+ // a surviving row of the removed identity.
4231
+ const progressSections = headingsForOwnTable
4232
+ .filter((heading) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(heading.text))
4233
+ .map((heading) => ({
4234
+ start: heading.offset,
4235
+ end: bracketMilestoneOwnTableEnd(content, heading.offset, headingsForOwnTable),
4236
+ ownedElsewhere: bracketProgressSectionOwnedByOtherMilestone(content, heading.offset, ranges, ownProgressSectionRanges),
4237
+ }));
4238
+ const inProgressSectionOwnedByOtherMilestone = (lineStart) => {
4239
+ const enclosing = progressSections.filter((section) => lineStart >= section.start && lineStart < section.end);
4240
+ return enclosing.length > 0 && enclosing[enclosing.length - 1].ownedElsewhere;
4241
+ };
4242
+ // A row is a Progress-table row only when the nearest heading above it is
4243
+ // a Progress-titled heading, a phase heading (the bare table that follows
4244
+ // the phase list), or the milestone or details heading that opens the
4245
+ // window. Any other heading, at any level, opens a different table (a
4246
+ // phase-keyed Requirements Traceability table), whose rows are never
4247
+ // deleted: a row keyed by the removed identity there refuses the removal.
4248
+ const windowAnchors = new Set(ranges ? [ranges.primary.start, ...(ranges.details ? [ranges.details.start] : [])] : []);
4249
+ const nearestHeadingKeepsProgressTable = (lineStart) => {
4250
+ let nearest;
4251
+ for (const heading of headingsForOwnTable) {
4252
+ if (heading.offset >= lineStart)
4253
+ break;
4254
+ nearest = heading;
2067
4255
  }
2068
- if (!isDecimal) {
2069
- // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
2070
- // is re-emitted verbatim — a tagged later phase still gets renumbered.
2071
- // #5007 (Phase 6 / ADR-4910 §8): composes phaseHeadingPrefixSrcFor
2072
- // directly (needs prefix/number/suffix as three separate capture groups
2073
- // for a verbatim in-place rewrite — neither shared owner returns that
2074
- // shape). Two deliberate departures from the design doc's suggested
2075
- // composition, both kept for zero behavior change on this
2076
- // highest-blast-radius site (feeds phase remove's renumber path):
2077
- // 1. The `#{2,4}\s*` heading marker is kept explicit and folded INTO
2078
- // the prefix capture group — the design's snippet omitted it
2079
- // entirely (phaseHeadingPrefixSrcFor never includes it; every
2080
- // caller adds it, e.g. buildPhaseHeadingScanRegex), and the
2081
- // original group 1 here captured the marker too.
2082
- // 2. No convention argument is threaded (LABEL_ONLY with
2083
- // convention=undefined), even though phaseHeadingPrefixSrcFor
2084
- // COULD accept one — this keeps the composed source byte-identical
2085
- // to the prior `Phase\s+` literal, a deliberate non-widening for
2086
- // this specific site (unlike cmdPhaseInsert's targetPattern).
2087
- // The number group widens from `\d+(?:\.\d+)?` to the full
2088
- // PHASE_NUMBER_TOKEN_SOURCE (letter suffix + multi-segment decimals) —
2089
- // this is SAFE, not a behavior change: decrementRoadmapPhaseToken's own
2090
- // regex (`^(\d+)(\.\d+)?$`) rejects anything wider and returns the
2091
- // token unchanged, so a heading this widening newly matches (e.g.
2092
- // "Phase 12A:") is written back byte-for-byte unchanged, identical to
2093
- // the pre-migration outcome of the whole regex simply not matching it.
2094
- content = content.replace(new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)})(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE}\\s*:)`, 'gi'), (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
2095
- content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
2096
- // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
2097
- // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
2098
- // directly above). The prior whole-document regex
2099
- // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
2100
- // file — including a same-shaped cell in an UNRELATED, earlier table
2101
- // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
2102
- // preceding `## Progress`; #2245-class scoping defect, same family as
2103
- // the row-delete fix above). Scoped here to the `## Progress` section
2104
- // only, mirroring that same section-slice-then-splice-back pattern.
2105
- //
2106
- // Loops because updateTableCell only rewrites the FIRST matching row
2107
- // per call. `processedOrdinalRows` tracks by row INDEX (stable across
2108
- // iterations — this only edits cell content, it never inserts/deletes
2109
- // rows) so an already-decremented row's new value — which may still
2110
- // numerically exceed `removedInt` — is never re-selected and
2111
- // decremented a second time (matching on the row's CURRENT value alone,
2112
- // without this guard, would keep re-firing on each pass).
2113
- //
2114
- // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
2115
- // required: a decimal sub-phase ordinal like `2.5` (no whitespace
2116
- // between the dot and the next character) never matches it, so it is
2117
- // left untouched — identical decimal-safety to the prior behaviour.
2118
- //
2119
- // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
2120
- // only, so the row's original leading/trailing alignment padding is
2121
- // recovered by a narrow, anchored lookup within that row's OWN raw
2122
- // line — addressed by ROW INDEX (`matchedRowIndex`, via
2123
- // `findDataRowLine`), not by searching the whole section for content
2124
- // matching the trimmed value (F8 #2245 review: two rows with identical
2125
- // trimmed Phase text, or a row whose already-rewritten new value
2126
- // coincides with another row's pre-edit text, would otherwise resolve
2127
- // to the WRONG row's padding — the first/leftmost content match found).
2128
- // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
2129
- // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
2130
- // escaped form, so searching for the unescaped `current` would
2131
- // silently fail to find an escaped-pipe cell's own line — preserving
2132
- // every other byte of the row (ADR-2143 §7 byte-parity) while only the
2133
- // digits actually change.
2134
- const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
2135
- if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
2136
- const ordinalHeadingOffset = ordinalHeadingMatch.index;
2137
- const ordinalBefore = content.slice(0, ordinalHeadingOffset);
2138
- const ordinalFromHeading = content.slice(ordinalHeadingOffset);
2139
- const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
2140
- let ordinalSection = ordinalNextHeadingOffset >= 0
2141
- ? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
2142
- : ordinalFromHeading;
2143
- const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
2144
- const phaseCellShapeRe = /^(\d+)(\.\s)/;
2145
- const processedOrdinalRows = new Set();
2146
- let matchedRowIndex = null;
2147
- for (;;) {
2148
- matchedRowIndex = null;
2149
- const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2150
- if (processedOrdinalRows.has(index))
2151
- return false;
2152
- const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
2153
- if (!m)
2154
- return false;
2155
- const num = parseInt(m[1], 10);
2156
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2157
- if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
2158
- return false;
2159
- processedOrdinalRows.add(index);
2160
- matchedRowIndex = index;
2161
- return true;
2162
- }, 'Phase', (current) => {
2163
- const m = phaseCellShapeRe.exec(current);
2164
- if (!m)
2165
- return current;
2166
- const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
2167
- const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2168
- const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2169
- const padMatch = targetLine
2170
- ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2171
- : null;
2172
- const leadPad = padMatch ? padMatch[1] : ' ';
2173
- const trailPad = padMatch ? padMatch[2] : ' ';
2174
- return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2175
- });
2176
- if (!cellResult.ok)
2177
- break;
2178
- ordinalSection = cellResult.value;
2179
- }
2180
- content = ordinalBefore + ordinalSection + ordinalRest;
4256
+ if (!nearest || windowAnchors.has(nearest.offset))
4257
+ return true;
4258
+ if ((0, roadmap_progress_cjs_1.isProgressHeadingTitle)(nearest.text))
4259
+ return true;
4260
+ return classifyBracketOwnedLine('#'.repeat(nearest.level) + ' ' + nearest.text).kind === 'heading';
4261
+ };
4262
+ // #4304: the referencesLeftUntouched report is computed
4263
+ // from each KEPT line's ORIGINAL (pre-rewrite) text, never the
4264
+ // persisted (already-rewritten) content: re-searching persisted text
4265
+ // for a pre-renumber id produces false positives whenever two or more
4266
+ // phases shift (a later phase's NEW value collides textually with an
4267
+ // earlier phase's OLD value). A line
4268
+ // that gets DELETED here (the target's own owned heading/checklist/
4269
+ // progress row) can never be "left untouched": it does not exist in
4270
+ // the output at all, so only kept lines are considered.
4271
+ const keptOriginalLines = [];
4272
+ const survivingTargetRows = [];
4273
+ const rewritten = [];
4274
+ for (const line of splitRoadmapLineRecords(content)) {
4275
+ const active = lineStartsInActiveMilestone(line.start, ranges);
4276
+ const historical = historicalLineStarts.has(line.start);
4277
+ const fenced = fencedLineNumbers.has(line.lineNumber);
4278
+ if (fenced) {
4279
+ rewritten.push(line.text + line.eol);
4280
+ keptOriginalLines.push({ text: line.text, active: false });
4281
+ continue;
4282
+ }
4283
+ const owned = classifyBracketOwnedLine(line.text);
4284
+ const underProgressTableHeading = owned.kind === 'progress' && nearestHeadingKeepsProgressTable(line.start);
4285
+ const inMilestoneOwnTable = underProgressTableHeading
4286
+ && (lineStartsInMilestoneOwnTable(content, line.start, ranges, headingsForOwnTable)
4287
+ || ownProgressSectionRanges.some((r) => line.start >= r.start && line.start < r.end));
4288
+ const inProgressSection = underProgressTableHeading && Boolean(progressSectionRange
4289
+ && !progressSectionOwnedElsewhere
4290
+ && line.start >= progressSectionRange.start
4291
+ && line.start < progressSectionRange.end);
4292
+ if (!historical
4293
+ && owned.id
4294
+ && sameBracketPhaseId(owned.id, targetId)
4295
+ && ((active && owned.kind === 'checklist') || inMilestoneOwnTable || inProgressSection)) {
4296
+ roadmapLinesRewritten += 1;
4297
+ continue;
4298
+ }
4299
+ if (!historical
4300
+ && owned.kind === 'progress'
4301
+ && owned.id
4302
+ && sameBracketPhaseId(owned.id, targetId)
4303
+ && !inProgressSectionOwnedByOtherMilestone(line.start)) {
4304
+ survivingTargetRows.push(line.text);
4305
+ }
4306
+ let next = line.text;
4307
+ if (!historical) {
4308
+ for (const { oldId, newId } of mapping) {
4309
+ next = replaceQualifiedBracketReference(next, oldId, newId);
4310
+ if (active)
4311
+ next = replaceBareBracketArtifactReference(next, oldId, newId);
2181
4312
  }
2182
- content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
2183
- // #5007 (Phase 6 / ADR-4910): migrated the "Depends on" bold-field
2184
- // decrement off two hand-rolled regexes — one per bold-placement
2185
- // variant, `**Depends on**: Phase N` and `**Depends on:** Phase N` —
2186
- // onto the PlanningDoc `boldField` read/write seam (`writePlansField`
2187
- // above is the established precedent for this migration shape).
2188
- // `BOLD_FIELD_RE` (planning-document.cts) recognizes BOTH placements
2189
- // as the SAME field label ("Depends on") — `parseBoldFieldLine` strips
2190
- // the token down to its inner text regardless of which side of `**`
2191
- // the colon lands on — so this single loop naturally replaces both
2192
- // regexes with one call site; no variant-specific branching remains.
2193
- // Unlike `writePlansField`'s single `findField` lookup (scoped to one
2194
- // already-isolated phase section), a whole ROADMAP.md can carry a
2195
- // "Depends on" field in EVERY phase's detail section, so this walks
2196
- // every `boldField` node in the whole parsed document whose label is
2197
- // "Depends on" and rewrites each one independently — a
2198
- // representability refusal or absent match on one field leaves that
2199
- // field untouched and never blocks any other field, mirroring the
2200
- // prior regexes' per-occurrence independence. A parse failure or an
2201
- // unreadable-nodes refusal on `serialize` leaves `content` byte-for-
2202
- // byte untouched, the same silent no-op the prior regexes had for any
2203
- // input they didn't match (neither ever threw or warned).
2204
- const dependsOnParsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'ROADMAP.md');
2205
- if (dependsOnParsed.ok) {
2206
- let dependsOnDoc = dependsOnParsed.value;
2207
- const dependsOnFieldIds = dependsOnDoc.nodes
2208
- .filter((n) => n.kind === 'boldField' && n.label === 'Depends on')
2209
- .map((n) => n.id);
2210
- for (const fieldId of dependsOnFieldIds) {
2211
- const currentRead = (0, planning_document_cjs_1.readNode)(dependsOnDoc, fieldId);
2212
- if (!currentRead.ok)
2213
- continue;
2214
- const depMatch = /^(Phase\s+)(\d+(?:\.\d+)?)\b/i.exec(currentRead.value);
2215
- if (!depMatch)
2216
- continue;
2217
- const [wholeMatch, prefix, num] = depMatch;
2218
- const newValue = `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}` + currentRead.value.slice(wholeMatch.length);
2219
- if (newValue === currentRead.value)
2220
- continue;
2221
- const staged = (0, planning_document_cjs_1.setFieldValue)(dependsOnDoc, fieldId, newValue);
2222
- if (staged.ok)
2223
- dependsOnDoc = staged.value;
2224
- }
2225
- const dependsOnOut = (0, planning_document_cjs_1.serialize)(dependsOnDoc);
2226
- if (dependsOnOut.ok)
2227
- content = dependsOnOut.value;
4313
+ }
4314
+ if (next !== line.text)
4315
+ roadmapLinesRewritten += 1;
4316
+ rewritten.push(next + line.eol);
4317
+ // A historical line can be physically inside the raw active milestone
4318
+ // range (closed details nested below the live milestone heading). It is
4319
+ // intentionally exempt from this mutation, so it is not an active
4320
+ // dangling reference for the removal report either.
4321
+ keptOriginalLines.push({ text: line.text, active: active && !historical });
4322
+ }
4323
+ content = rewritten.join('');
4324
+ return { content, roadmapLinesRewritten, keptOriginalLines, survivingTargetRows };
4325
+ }
4326
+ /**
4327
+ * Remove the active bracket phase and renumber its later identities. Qualified
4328
+ * references are rewritten roadmap-wide, including global sections and other
4329
+ * project-code milestone sections, except for lines inside archived details or
4330
+ * closed milestone sections. Those historical lines are never rewritten or
4331
+ * deleted. Bare artifact references remain limited to the active milestone.
4332
+ */
4333
+ function updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, context, mapping, cwd) {
4334
+ return withPlanningLock(cwd, () => {
4335
+ const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
4336
+ const targetId = bracketPhaseId(context, removedInt, removedSubphase);
4337
+ const { content, roadmapLinesRewritten, keptOriginalLines } = planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd);
4338
+ const bracketNormalization = { preserveFencedMarkdownStructure: true };
4339
+ (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content, bracketNormalization);
4340
+ // platformWriteSync's own markdown normalization (_normalizeMd) inserts
4341
+ // blank lines around headings/fences/lists and collapses runs of 3+
4342
+ // blank lines, so a KEPT line's position here can shift from its
4343
+ // position in `keptOriginalLines`. Normalization only ever adds or
4344
+ // collapses BLANK lines — it never reorders or edits a non-blank
4345
+ // line's text — so the Nth non-blank kept (original) line always
4346
+ // corresponds to the Nth non-blank persisted line; blank kept lines
4347
+ // are skipped entirely since they can never contain a tracked identity.
4348
+ const persistedContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
4349
+ const persistedNonBlankLineNumbers = splitRoadmapLineRecords(persistedContent)
4350
+ .filter((line) => line.text.trim() !== '')
4351
+ .map((line) => line.lineNumber);
4352
+ const referencesLeftUntouched = [];
4353
+ let nonBlankIndex = 0;
4354
+ for (const { text, active } of keptOriginalLines) {
4355
+ if (text.trim() === '')
4356
+ continue;
4357
+ const persistedLineNumber = persistedNonBlankLineNumbers[nonBlankIndex];
4358
+ nonBlankIndex += 1;
4359
+ if (!active || persistedLineNumber === undefined)
4360
+ continue;
4361
+ if (lineContainsTrackedBracketIdentity(text, targetId, mapping)) {
4362
+ referencesLeftUntouched.push(persistedLineNumber);
2228
4363
  }
2229
4364
  }
2230
- (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
2231
- // #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
2232
- // persists), not the raw pre-normalize `content` string, against the raw
2233
- // pre-mutation `originalContent` read above — a raw `!==` here reports a
2234
- // false `true` whenever this transform's regenerated output takes a
2235
- // different-but-equivalent shape than the already-normalized on-disk
2236
- // original (same normalization-order artifact #3685 fixed at
2237
- // cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
2238
- return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
4365
+ return {
4366
+ updated: (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content, bracketNormalization),
4367
+ roadmapLinesRewritten,
4368
+ referencesLeftUntouched,
4369
+ };
2239
4370
  });
2240
4371
  }
2241
4372
  /**
2242
- * #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
2243
- * the leading frontmatter block's closing `---` fence — so a body field never
2244
- * lands before the opening fence. The former whole-content prepend
2245
- * (`field + content`) put the line ABOVE the opening `---`, and
2246
- * syncStateFrontmatter then treated the scrambled fence structure as TWO
2247
- * frontmatter blocks, rebuilding a derived one on top of the original
2248
- * (milestone_name from a ROADMAP heading, total_phases counting the removed
2249
- * phase, a stray 'Total Phases: 0' between fences). A file with no leading
2250
- * frontmatter is all body: the field goes to content start, preserving the
2251
- * former behavior for that shape.
2252
- *
2253
- * The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
2254
- * and every STATE.md reader agree on where the body starts. The blank line and the field go
2255
- * right after the closing fence line, ended by that line's own line ending — so a CRLF file
2256
- * gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
2257
- * return; joining the new lines on a bare '\n' mixed line endings).
4373
+ * #4304: phase ids the reader lists but the bracket grammar cannot represent
4374
+ * (a letter suffix such as `02a`, or a three-level `02.01.01`) are invisible
4375
+ * to the renumber mapping and the owned-line rewrite. Removing a phase at or
4376
+ * below them would leave them on their old numbers beside renumbered
4377
+ * siblings, so they are reported here, from the live (unfenced, not
4378
+ * historical) lines of the active milestone, for the caller to refuse.
4379
+ * An integer removal is affected by every such id whose leading number is
4380
+ * at or above the removed one; a sub-phase removal by those sharing its
4381
+ * leading number.
2258
4382
  */
2259
- function insertStateBodyFieldAtTop(content, fieldLine) {
2260
- const fence = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(content);
2261
- if (fence?.closed) {
2262
- const nl = content[fence.closingFenceEnd] === '\r' ? '\r\n' : '\n';
2263
- return `${content.slice(0, fence.closingFenceEnd)}${nl}${nl}${fieldLine}${content.slice(fence.closingFenceEnd)}`;
4383
+ function unrepresentableBracketIdsAffectedByRemoval(content, ranges, removedInt, removedSubphase) {
4384
+ if (!ranges)
4385
+ return [];
4386
+ const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
4387
+ const fencedLineNumbers = fencedRoadmapLineNumbers(content);
4388
+ const liveActive = splitRoadmapLineRecords(content)
4389
+ .map((line) => (lineStartsInActiveMilestone(line.start, ranges)
4390
+ && !historicalLineStarts.has(line.start)
4391
+ && !fencedLineNumbers.has(line.lineNumber)
4392
+ ? line.text
4393
+ : ''))
4394
+ .join('\n');
4395
+ const affected = [];
4396
+ for (const id of scanMilestonePhaseIds(liveActive, 'bracket')) {
4397
+ // The owned-line grammar canonicalizes each numeric segment through
4398
+ // `phaseToken` (`002` reads as `02`), so representable means one or two
4399
+ // numeric segments that each canonicalize.
4400
+ const segments = String(id).split('.');
4401
+ if (segments.length <= 2 && segments.every((segment) => /^\d+$/.test(segment) && phaseToken(segment) !== null)) {
4402
+ continue;
4403
+ }
4404
+ const leading = /^(\d+)/.exec(String(id));
4405
+ if (!leading)
4406
+ continue;
4407
+ const phase = Number(leading[1]);
4408
+ if (removedSubphase === undefined ? phase >= removedInt : phase === removedInt)
4409
+ affected.push(String(id));
2264
4410
  }
2265
- // All body: the field is the new first line, ended like the document's first line.
2266
- const firstNewline = content.indexOf('\n');
2267
- return fieldLine + (firstNewline > 0 && content[firstNewline - 1] === '\r' ? '\r\n' : '\n') + content;
4411
+ return affected;
4412
+ }
4413
+ /**
4414
+ * #4304: `phase remove` adjusts only STATE.md's phase counts. When STATE.md's
4415
+ * current phase (the canonical frontmatter, `Current Phase`, scoped prose
4416
+ * ladder) is the removed identity or one the removal renumbers, every
4417
+ * current-phase field would keep naming the old identity, which afterwards
4418
+ * belongs to a different phase. Returns that current phase for the caller to
4419
+ * refuse on, or null.
4420
+ */
4421
+ function stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, mapping) {
4422
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
4423
+ if (!node_fs_1.default.existsSync(statePath))
4424
+ return null;
4425
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
4426
+ const fm = frontmatterMod.extractFrontmatter(rawState, statePath);
4427
+ const current = stateMod.resolveCurrentPhaseId(fm, frontmatterMod.stripFrontmatter(rawState));
4428
+ if (current === null || current === undefined)
4429
+ return null;
4430
+ let currentToken = String(current).trim();
4431
+ if (currentToken.includes('-')) {
4432
+ try {
4433
+ currentToken = renderPhaseId(parsePhaseId(currentToken));
4434
+ }
4435
+ catch {
4436
+ return null;
4437
+ }
4438
+ }
4439
+ const match = new RegExp(`^(?:\\[(${phaseIdMod.BRACKET_PROJECT_CODE_SRC})\\.(\\d+)\\][ \\t]*)?(\\d+)(?:\\.(\\d+))?$`, 'i').exec(currentToken);
4440
+ if (!match)
4441
+ return null;
4442
+ // A current phase qualified with another project or milestone is not the
4443
+ // active milestone's phase, whatever its number.
4444
+ if (match[1] !== undefined
4445
+ && (match[1].toUpperCase() !== targetId.project || Number(match[2]) !== Number(targetId.milestone))) {
4446
+ return null;
4447
+ }
4448
+ const phase = Number(match[3]);
4449
+ const subphase = match[4] === undefined ? undefined : Number(match[4]);
4450
+ const sameNumber = (id) => Number(id.phase) === phase
4451
+ && (id.subphase === undefined ? subphase === undefined : Number(id.subphase) === subphase);
4452
+ return sameNumber(targetId) || mapping.some(({ oldId }) => sameNumber(oldId)) ? String(current).trim() : null;
2268
4453
  }
2269
4454
  function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2270
4455
  if (!targetPhase)
@@ -2273,22 +4458,78 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2273
4458
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
2274
4459
  if (!node_fs_1.default.existsSync(roadmapPath))
2275
4460
  error('ROADMAP.md not found');
2276
- const normalized = normalizePhaseName(targetPhase);
2277
- const isDecimal = targetPhase.includes('.');
2278
4461
  const force = options.force || false;
4462
+ const removeConvention = resolvePhaseIdConvention(cwd);
4463
+ const removeContext = removeConvention === 'bracket'
4464
+ ? bracketWriteContext(cwd, loadConfig(cwd))
4465
+ : null;
4466
+ // #4304: canonicalize every bracket argument before
4467
+ // directory matching or any write, through the SAME adapter `phase
4468
+ // insert` now uses (canonicalizeBracketPhaseArgument) — a bare token
4469
+ // through phase-id-display's `phaseToken`, a qualified/display token
4470
+ // through phase-id.cts's strict `parsePhaseId`. A rejected spelling has no
4471
+ // parseInt fallback, so it cannot partially name a real phase and reach
4472
+ // the destructive path.
4473
+ let normalized;
4474
+ let isDecimal;
4475
+ let requestedSlug;
4476
+ let removedInt;
4477
+ let removedSubphase;
4478
+ if (removeContext) {
4479
+ ({ normalized, isDecimal, slug: requestedSlug } = canonicalizeBracketPhaseArgument(removeContext, targetPhase, 'remove'));
4480
+ const [phasePart, subphasePart] = normalized.split('.');
4481
+ removedInt = Number(phasePart);
4482
+ removedSubphase = subphasePart === undefined ? undefined : Number(subphasePart);
4483
+ if (removedSubphase === 0) {
4484
+ error('Cannot remove a .00 sub-phase: bracket sub-phases start at .01.', ERROR_REASON.USAGE);
4485
+ }
4486
+ }
4487
+ else {
4488
+ // Legacy and milestone-prefixed paths retain their original permissive
4489
+ // normalization and parseInt behavior byte-for-byte.
4490
+ normalized = normalizePhaseName(targetPhase);
4491
+ isDecimal = targetPhase.includes('.');
4492
+ removedInt = parseInt(normalized, 10);
4493
+ removedSubphase = isDecimal ? parseInt(normalized.split('.')[1], 10) : undefined;
4494
+ }
2279
4495
  const subdirs = readSubdirectories(phasesDir, true);
2280
4496
  // #2237/#2528: every other resolution path refuses to choose between multiple
2281
4497
  // directories claiming one phase number. This one is the DESTRUCTIVE path, so
2282
4498
  // taking `matches[0]` silently is strictly worse than anywhere else: it turns
2283
4499
  // "resolve nothing" into "delete one of two candidates, unrecoverably, and
2284
4500
  // renumber every phase after it". Refuse before any file is touched.
2285
- const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
4501
+ const candidateDirs = removeContext
4502
+ ? bracketIdsInContext(phasesDir, removeContext).map(({ dir }) => dir)
4503
+ : subdirs;
4504
+ const directoryContext = phaseDirectoryOptions(cwd);
4505
+ let { matches: phaseDirMatches } = matchPhaseDirs(candidateDirs, removeConvention === 'bracket' ? normalized : phaseLookupQuery(targetPhase, directoryContext), removeConvention === 'bracket' ? 'bracket' : undefined, undefined, directoryContext);
4506
+ if (removeContext && requestedSlug !== undefined) {
4507
+ phaseDirMatches = phaseDirMatches.filter((dir) => bracketDirSlug(dir, parsePhaseId(dir)) === requestedSlug);
4508
+ if (phaseDirMatches.length !== 1) {
4509
+ error(`Cannot remove ${targetPhase}: its slug does not exactly match a directory for phase ${normalized}.`, ERROR_REASON.USAGE);
4510
+ }
4511
+ }
4512
+ const uniqueCustomTarget = phaseDirMatches.length === 1
4513
+ && phaseIdMod.customDirectoryCandidates(phaseDirMatches[0], directoryContext).length === 1;
4514
+ const mutationCandidates = subdirs.filter(dir => {
4515
+ const owners = phaseIdMod.customDirectoryCandidates(dir, directoryContext);
4516
+ // A unique proven target excludes siblings whose longer declared owner
4517
+ // wins resolution. Unresolved/ambiguous queries retain the full refusal.
4518
+ return dir === targetPhase || (owners.includes(targetPhase) && (!uniqueCustomTarget || dir === phaseDirMatches[0]));
4519
+ });
4520
+ // The numeric renumbering set comes from the renamers' own selector.
4521
+ const renumbered = removeContext || isCustomPhaseId(targetPhase) ? [] : (isDecimal
4522
+ ? selectDecimalRenames(subdirs, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext)
4523
+ : selectIntegerRenames(subdirs, parseInt(normalized, 10), directoryContext)).map(item => item.dir);
4524
+ assertUnambiguousCustomDirectories([...new Set([...mutationCandidates, ...renumbered])], directoryContext);
2286
4525
  if (phaseDirMatches.length > 1) {
2287
4526
  output({
2288
4527
  removed: null,
2289
4528
  error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
2290
4529
  + `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
2291
- + 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
4530
+ + (removeContext
4531
+ ? 'Remove the stale duplicate directory or give it a distinct bracket phase identity, then retry.'
4532
+ : 'Set a distinct project_code in .planning/config.json, or pass the full directory name.'),
2292
4533
  ambiguous_matches: phaseDirMatches,
2293
4534
  directory_deleted: null,
2294
4535
  renamed_directories: [],
@@ -2299,6 +4540,30 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2299
4540
  return;
2300
4541
  }
2301
4542
  const targetDir = phaseDirMatches[0] || null;
4543
+ const customTarget = targetDir !== null && (directoryContext.directoryIds?.[targetDir] !== undefined
4544
+ || phaseIdMod.customDirectoryCandidates(targetDir, directoryContext).length > 0);
4545
+ const stableIds = new Set(subdirs.flatMap(dir => phaseIdMod.customDirectoryCandidates(dir, directoryContext))
4546
+ .map(id => (isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase()));
4547
+ const roadmapContentBeforeRemoval = removeContext ? node_fs_1.default.readFileSync(roadmapPath, 'utf-8') : null;
4548
+ if (removeContext) {
4549
+ const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4550
+ const legacyOnly = legacyOnlyBracketRemovalTarget(subdirs, roadmapContentBeforeRemoval, targetPhase, targetId, targetDir !== null);
4551
+ if (legacyOnly) {
4552
+ const evidence = [
4553
+ ...legacyOnly.directories.map((dir) => `directory ${JSON.stringify(dir)}`),
4554
+ ...legacyOnly.headings.map((heading) => `heading ${JSON.stringify(heading)}`),
4555
+ ].join('; ');
4556
+ // roadmap upgrade converts nothing once config selects bracket (it
4557
+ // refuses a partial migration and treats an all-bracket ROADMAP as
4558
+ // done), so name the conversion itself.
4559
+ const directoryPrefix = legacyOnly.directories.length > 0
4560
+ ? ` (directory prefix ${JSON.stringify(bracketDirPrefix(targetId))})`
4561
+ : '';
4562
+ error(`Cannot remove phase ${normalized} under the bracket convention: it resolves only to `
4563
+ + `legacy-spelled artifacts (${evidence}). Convert them to the bracket spelling `
4564
+ + `${renderPhaseId(targetId)}${directoryPrefix} before removing this phase.`);
4565
+ }
4566
+ }
2302
4567
  if (targetDir && !force) {
2303
4568
  // #3183: canonical summary set (root+nested) from the single owner —
2304
4569
  // a root-only readdirSync filter left nested (#3139 layout) summaries
@@ -2309,6 +4574,113 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2309
4574
  error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
2310
4575
  }
2311
4576
  }
4577
+ // #4304: compute the ONE renumber mapping shared by the disk
4578
+ // rename and the ROADMAP rewrite before either runs, from the roadmap
4579
+ // content as it stands right now (only the target directory is about to
4580
+ // be deleted below; deletion does not change which OTHER identities the
4581
+ // scan finds). Both consumers below apply this exact mapping so they
4582
+ // cannot independently diverge on a decimal sub-phase identity.
4583
+ const preRemovalRanges = roadmapContentBeforeRemoval
4584
+ ? currentMilestoneRawRanges(roadmapContentBeforeRemoval, cwd, 'bracket')
4585
+ : null;
4586
+ // #4304: refuse before any mutation when the target's own
4587
+ // heading/checklist line lives entirely OUTSIDE the milestone window the
4588
+ // read side located — a mislocated window (a decoy heading carrying the
4589
+ // active version token before the real milestone heading, a document-level
4590
+ // Progress heading placed before it, or no milestone heading at all)
4591
+ // otherwise lets the destructive delete/rename below proceed while the
4592
+ // ROADMAP rewrite silently never reaches the target's real lines. Does not
4593
+ // attempt to fix the window's own selection (PR-6 / #4751 territory) —
4594
+ // only refuses instead of half-applying.
4595
+ //
4596
+ // #4304: NOT additionally gated on `targetDir` — a ROADMAP-only
4597
+ // target (a phase `phase add` created with no directory materialized yet)
4598
+ // on a mislocated window still half-applied under the `targetDir` gate:
4599
+ // later directories renamed and their ROADMAP lines renumbered onto the
4600
+ // target's identity while the target's own heading/checklist survived,
4601
+ // with an empty report. The guard reads only ROADMAP content (never
4602
+ // `targetDir` itself), so gating it on a directory that may not exist
4603
+ // protected nothing; with no directory and no matching ROADMAP line the
4604
+ // guard already reports no outside line.
4605
+ if (removeContext) {
4606
+ const guardTargetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4607
+ const { outside, hasLiveTarget } = bracketOwnedLineOutsideActiveWindow(roadmapContentBeforeRemoval, guardTargetId, preRemovalRanges);
4608
+ if (outside) {
4609
+ const windowDescription = preRemovalRanges
4610
+ ? `the located milestone window starting at line ${roadmapContentBeforeRemoval.slice(0, preRemovalRanges.primary.start).split('\n').length} (${roadmapContentBeforeRemoval
4611
+ .slice(preRemovalRanges.primary.start, roadmapContentBeforeRemoval.indexOf('\n', preRemovalRanges.primary.start))
4612
+ .trim()})`
4613
+ : 'no milestone heading at all';
4614
+ error(`Cannot remove phase ${normalized}: its own heading/checklist line `
4615
+ + `(ROADMAP.md line ${outside.lineNumber}: ${JSON.stringify(outside.text)}) `
4616
+ + `lies outside ${windowDescription}. ROADMAP.md's milestone heading was not `
4617
+ + 'found where expected; verify the ROADMAP.md structure before removing.', ERROR_REASON.USAGE);
4618
+ }
4619
+ if (targetDir === null && !hasLiveTarget) {
4620
+ error(`Cannot remove phase ${normalized}: no directory or live heading/checklist exists in the active milestone window.`, ERROR_REASON.USAGE);
4621
+ }
4622
+ }
4623
+ // #4304: refuse before any mutation when an INTEGER phase
4624
+ // still has its own sub-phases. Without this, computeBracketRenumberMapping's
4625
+ // filter (phase > removedInt only) never touches the removed phase's OWN
4626
+ // sub-phases: they stay orphaned on disk/ROADMAP while the NEXT phase's
4627
+ // sub-phases renumber onto the SAME identities, manufacturing duplicates
4628
+ // (legacy has the same defect on its own path — parity, not fixed there).
4629
+ if (removeContext && removedSubphase === undefined) {
4630
+ const ownSubphases = bracketPhaseOwnSubphases(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt);
4631
+ if (ownSubphases.length > 0) {
4632
+ error(`Cannot remove phase ${normalized}: it still has sub-phase(s) `
4633
+ + `${ownSubphases.map((id) => renderPhaseId(id)).join(', ')}. `
4634
+ + 'Remove the sub-phase(s) first, then remove the phase.');
4635
+ }
4636
+ }
4637
+ const bracketMapping = removeContext
4638
+ ? computeBracketRenumberMapping(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase)
4639
+ : [];
4640
+ // #4304: the mapping inventories only canonical bracket spellings. Refuse
4641
+ // before any mutation while a legacy spelling resolves to an identity it
4642
+ // deletes, renumbers, or renumbers onto; renumbering around it would leave
4643
+ // that spelling on its old number for the reader to misattribute.
4644
+ if (removeContext) {
4645
+ const entangled = legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase, bracketMapping);
4646
+ if (entangled.length > 0) {
4647
+ error(`Cannot remove phase ${normalized}: phase remove renumbers only bracket spellings, and `
4648
+ + `${entangled.length === 1 ? 'this legacy spelling resolves' : 'these legacy spellings resolve'} `
4649
+ + `to a phase it would delete or renumber: ${entangled.map(describeEntangledLegacySpelling).join('; ')}. `
4650
+ + 'Convert each to its bracket spelling, then retry.');
4651
+ }
4652
+ }
4653
+ if (removeContext) {
4654
+ const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
4655
+ const unrepresentable = unrepresentableBracketIdsAffectedByRemoval(roadmapContentBeforeRemoval, preRemovalRanges, removedInt, removedSubphase);
4656
+ if (unrepresentable.length > 0) {
4657
+ error(`Cannot remove phase ${normalized}: the active milestone lists phase id(s) `
4658
+ + `${unrepresentable.join(', ')} that are not representable in the bracket convention, `
4659
+ + 'so phase remove can neither delete nor renumber them alongside their siblings. '
4660
+ + 'Renumber or remove them first, then retry.');
4661
+ }
4662
+ const plan = planBracketRoadmapRemoval(roadmapContentBeforeRemoval, targetId, bracketMapping, cwd);
4663
+ if (plan.survivingTargetRows.length > 0) {
4664
+ error(`Cannot remove phase ${normalized}: ROADMAP.md has table row(s) keyed by ${renderPhaseId(targetId)} `
4665
+ + `outside the milestone's own Progress tables (${plan.survivingTargetRows.map((row) => JSON.stringify(row.trim())).join(', ')}). `
4666
+ + 'Removing the phase would leave each row naming an identity a renumbered sibling takes over. '
4667
+ + 'Delete or re-key those rows first, then retry.');
4668
+ }
4669
+ const currentPhase = stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, bracketMapping);
4670
+ if (currentPhase !== null) {
4671
+ error(`Cannot remove phase ${renderPhaseId(targetId)}: STATE.md's current phase is ${currentPhase}, which this `
4672
+ + 'removal deletes or renumbers, and phase remove does not rewrite the current-phase fields. '
4673
+ + 'Bracket phase remove deletes only phases after the current phase. To remove this one, point STATE.md\'s '
4674
+ + `current phase at a phase before ${renderPhaseId(targetId)}, or, when there is none, clear the current-phase `
4675
+ + 'fields (frontmatter current_phase, the Current Phase field and the Current Position Phase line), then retry.');
4676
+ }
4677
+ }
4678
+ // Validate every bracket rename destination before deleting the target or
4679
+ // changing ROADMAP/STATE, so a planted symlink produces a clean refusal
4680
+ // with the planning tree untouched.
4681
+ if (removeContext) {
4682
+ assertBracketRenameDestinationsSafe(phasesDir, removeContext, bracketMapping, targetDir);
4683
+ }
2312
4684
  // #5118 (no write before the error): the STATE.md rewrite below rebuilds the
2313
4685
  // frontmatter from EVERY surviving phase's report (buildStateFrontmatter →
2314
4686
  // isPhaseComplete), and it runs AFTER the directory removal, the sibling
@@ -2330,13 +4702,22 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2330
4702
  let renamedFiles = [];
2331
4703
  let renamedFileCollisions = [];
2332
4704
  try {
2333
- if (isDecimal) {
2334
- const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
4705
+ if (removeContext) {
4706
+ // #4304: reuse the SAME removedInt/removedSubphase validated
4707
+ // above (before deletion) instead of re-deriving from `normalized`,
4708
+ // which is NaN for a qualified id like `CK.02-02`.
4709
+ const renamed = renameBracketPhases(phasesDir, removeContext, bracketMapping);
2335
4710
  renamedDirs = renamed.renamedDirs;
2336
4711
  renamedFiles = renamed.renamedFiles;
4712
+ renamedFileCollisions = renamed.renamedFileCollisions;
2337
4713
  }
2338
- else {
2339
- const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
4714
+ else if (!customTarget && isDecimal) {
4715
+ const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext);
4716
+ renamedDirs = renamed.renamedDirs;
4717
+ renamedFiles = renamed.renamedFiles;
4718
+ }
4719
+ else if (!customTarget) {
4720
+ const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10), directoryContext);
2340
4721
  renamedDirs = renamed.renamedDirs;
2341
4722
  renamedFiles = renamed.renamedFiles;
2342
4723
  renamedFileCollisions = renamed.renamedFileCollisions;
@@ -2355,7 +4736,14 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2355
4736
  const msg = e instanceof Error ? e.message : String(e);
2356
4737
  error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
2357
4738
  }
2358
- const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
4739
+ const bracketRoadmapRewrite = removeContext
4740
+ ? updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, removeContext, bracketMapping, cwd)
4741
+ : null;
4742
+ const roadmapUpdated = bracketRoadmapRewrite
4743
+ ? bracketRoadmapRewrite.updated
4744
+ : updateRoadmapAfterPhaseRemoval(roadmapPath, directoryContext.directoryIds?.[targetDir ?? '']
4745
+ ?? (targetDir ? phaseIdMod.customDirectoryCandidates(targetDir, directoryContext)[0] : undefined)
4746
+ ?? targetPhase, isDecimal, parseInt(normalized, 10), cwd, customTarget, stableIds);
2359
4747
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
2360
4748
  let stateUpdated = false;
2361
4749
  if (node_fs_1.default.existsSync(statePath)) {
@@ -2433,6 +4821,12 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2433
4821
  // change, not hardcoded regardless of whether ROADMAP.md's content
2434
4822
  // actually changed.
2435
4823
  roadmap_updated: roadmapUpdated,
4824
+ ...(bracketRoadmapRewrite
4825
+ ? {
4826
+ roadmap_lines_rewritten: bracketRoadmapRewrite.roadmapLinesRewritten,
4827
+ references_left_untouched: bracketRoadmapRewrite.referencesLeftUntouched,
4828
+ }
4829
+ : {}),
2436
4830
  state_updated: stateUpdated,
2437
4831
  }, raw);
2438
4832
  // #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
@@ -3180,6 +5574,7 @@ function auditArtifactWarnings(cwd, phaseFullDir) {
3180
5574
  return out;
3181
5575
  }
3182
5576
  function cmdPhaseComplete(cwd, phaseNum, raw) {
5577
+ const directoryContext = phaseDirectoryOptions(cwd);
3183
5578
  if (!phaseNum) {
3184
5579
  error('phase number required for phase complete');
3185
5580
  }
@@ -3216,7 +5611,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3216
5611
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
3217
5612
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
3218
5613
  const today = clock_cjs_1.realClock.localToday();
3219
- const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
5614
+ const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
3220
5615
  if (!phaseInfoRaw) {
3221
5616
  error(`Phase ${phaseNum} not found`);
3222
5617
  }
@@ -3488,7 +5883,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3488
5883
  if (node_fs_1.default.existsSync(roadmapPath)) {
3489
5884
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
3490
5885
  roadmapContent = originalRoadmapContent;
3491
- const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
5886
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum, directoryContext);
3492
5887
  // #2067: the gap between `]` and `Phase N` must allow only whitespace /
3493
5888
  // markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
3494
5889
  // phase whose description merely mentioned the completed phase number,
@@ -3521,9 +5916,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3521
5916
  // bug because a GSD-authored ROADMAP.md milestone checklist never puts
3522
5917
  // its own `- [ ] Phase N: …` entries inside a fenced code block, but it
3523
5918
  // is a real (and correct) behavioural difference on pathological input.
3524
- const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
5919
+ // The phase-entry grammar's split regexp (#5764): group 1 is everything through the
5920
+ // opening `[` (indent included, so a flip keeps the entry's indent), group 2 the rest.
5921
+ const entryTailSrc = `Phase[ \\t]+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`;
5922
+ const checkboxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, 'i', { state: 'unchecked' });
3525
5923
  // #5165: pre-mutation read of the same checklist line, checked this time.
3526
- const checkedBoxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[xX](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`);
5924
+ const checkedBoxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, '', { state: 'checked' });
3527
5925
  const originalMilestoneScope = extractCurrentMilestone(originalRoadmapContent, cwd);
3528
5926
  phaseWasAlreadyComplete = originalMilestoneScope
3529
5927
  .split('\n')
@@ -3550,37 +5948,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3550
5948
  // milestone ROADMAP binds, so a shipped milestone's same-numbered phase
3551
5949
  // cannot count.
3552
5950
  if (!phaseWasAlreadyComplete) {
3553
- const progressTable = locateProgressTable(originalMilestoneScope);
5951
+ const progressTable = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(originalMilestoneScope).table;
3554
5952
  if (progressTable !== null) {
3555
5953
  const wantMilestone = progressTable.columns.includes('Milestone')
3556
- ? normalizeMilestoneToken(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
5954
+ ? (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
3557
5955
  : null;
3558
5956
  phaseWasAlreadyComplete = progressTable.rows.some((row) => rowMatch(row)
3559
5957
  && (0, phase_status_cjs_1.parseRoadmapStatusCell)(row['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
3560
- && (wantMilestone === null || (wantMilestone !== '' && normalizeMilestoneToken(row['Milestone']) === wantMilestone)));
5958
+ && (wantMilestone === null || (wantMilestone !== '' && (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(row['Milestone']) === wantMilestone)));
3561
5959
  }
3562
5960
  }
3563
- /**
3564
- * Within `text` (already scoped to one milestone window by the
3565
- * caller), scope further to the `## Progress` heading section (up to
3566
- * the next `#`/`##` heading) when present, run `edit` against just
3567
- * that slice, and splice the result back — falling back to the whole
3568
- * `text` when no `## Progress` heading exists (mirrors phase-
3569
- * lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
3570
- */
3571
- const editProgressHeadingSlice = (text, edit) => {
3572
- const progressMatch = text.match(/^##[ \t]+Progress\b/im);
3573
- if (!progressMatch || progressMatch.index === undefined) {
3574
- return edit(text);
3575
- }
3576
- const headingOffset = progressMatch.index;
3577
- const beforeHeading = text.slice(0, headingOffset);
3578
- const fromHeading = text.slice(headingOffset);
3579
- const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
3580
- const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
3581
- const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
3582
- return beforeHeading + edit(scoped) + after;
3583
- };
3584
5961
  // ADR-2143 §4: the plan-count write is now routed through
3585
5962
  // withPhaseSection (see mutateMilestonePhase below), which hands this
3586
5963
  // seam call ONLY phase N's own detail-section body — the section
@@ -3692,7 +6069,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3692
6069
  const mutateMilestonePhase = (slice) => {
3693
6070
  let s = slice;
3694
6071
  s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
3695
- s = editProgressHeadingSlice(s, (scoped) => {
6072
+ // #5762: `s` is already one milestone window; the Progress slice is the owner's
6073
+ // (`editProgressTableSlice` -> `editRoadmapProgress`), the same one `roadmap update-plan-progress` uses.
6074
+ s = editProgressTableSlice(s, (scoped) => {
3696
6075
  let text = scoped;
3697
6076
  const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
3698
6077
  if (plansResult.ok)
@@ -3732,39 +6111,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3732
6111
  if (!coreUtilsMod.summaryPairsWithAnyPlan(phaseInfo['plans'] ?? [], summaryFile))
3733
6112
  continue;
3734
6113
  // ADR-5057 §6 (Phase 13, #5217): the per-plan flip is a bullet
3735
- // mutation, so it goes through `updateBullet` like the phase
6114
+ // mutation (`updateBullet` inside the helper) like the phase
3736
6115
  // checkbox flip above — first matching bullet only, never a
3737
6116
  // fenced line, never a mid-line occurrence.
3738
- const planCheckboxPattern = coreUtilsMod.planRowPattern(planId, { anchored: true });
3739
- b = (0, markdown_sectionizer_cjs_1.updateBullet)(b, (_bulletText, rawLine) => planCheckboxPattern.test(rawLine), (rawLine) => rawLine.replace(planCheckboxPattern, '$1x$2'));
6117
+ b = coreUtilsMod.tickAnchoredPlanRow(b, planId);
3740
6118
  }
3741
6119
  return b;
3742
6120
  });
3743
6121
  return s;
3744
6122
  };
3745
- const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
3746
- if (milestoneRanges) {
3747
- // Splice later windows first so an earlier window's offsets are not
3748
- // shifted by a length-changing mutation in a later window.
3749
- const windows = [milestoneRanges.details, milestoneRanges.primary]
3750
- .filter((w) => w !== null)
3751
- .sort((a, b) => b.start - a.start);
3752
- for (const w of windows) {
3753
- roadmapContent =
3754
- roadmapContent.slice(0, w.start)
3755
- + mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
3756
- + roadmapContent.slice(w.end);
3757
- }
3758
- }
3759
- else {
3760
- // #5543: the active milestone could not be resolved (no STATE
3761
- // `milestone:`, no marker). Writing the whole document would let
3762
- // `withPhaseSection` pick the first `### Phase N:` in document order,
3763
- // which can sit inside a shipped `<details>` block. Hide the shipped
3764
- // blocks from the mutation and restore them untouched; a ROADMAP with
3765
- // no shipped block is mutated whole, as before.
3766
- roadmapContent = editOutsideShippedBlocks(roadmapContent, mutateMilestonePhase);
3767
- }
6123
+ roadmapContent = editCurrentMilestone(roadmapContent, cwd, mutateMilestonePhase);
3768
6124
  writes.push({
3769
6125
  filePath: roadmapPath,
3770
6126
  before: originalRoadmapContent,
@@ -3778,7 +6134,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3778
6134
  roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
3779
6135
  const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
3780
6136
  if (node_fs_1.default.existsSync(reqPath)) {
3781
- const phaseEsc = phaseMarkdownRegexSource(phaseNum);
6137
+ const phaseEsc = phaseMarkdownRegexSource(phaseNum, directoryContext);
3782
6138
  const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
3783
6139
  // #5007 (Phase 6 / ADR-4910 §8): this site captures the entire
3784
6140
  // section BODY through to the next heading (via its own
@@ -3872,7 +6228,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3872
6228
  // checkbox rollback below can distinguish "row existed and accepted" from
3873
6229
  // "row existed and rejected".
3874
6230
  let tableHit = false;
3875
- const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
6231
+ const reqUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, reqRowMatch, 'Status', (current) => {
3876
6232
  // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
3877
6233
  // gaps_found response) can complete without hand-editing the table.
3878
6234
  if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
@@ -4059,7 +6415,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4059
6415
  // technique milestone.cts's `hasRow` uses (:210-214): a case-
4060
6416
  // insensitive first-cell match, regardless of current Status.
4061
6417
  const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
4062
- return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
6418
+ return (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
4063
6419
  };
4064
6420
  const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
4065
6421
  if (ghostReqIds.length > 0) {
@@ -4114,6 +6470,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4114
6470
  // where the roadmap would give `2` and a slugified title. Promoting the
4115
6471
  // roadmap without this would silently change the reported value on
4116
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);
4117
6476
  let diskNextNum = null;
4118
6477
  let diskNextName = null;
4119
6478
  let roadmapNextNum = null;
@@ -4126,16 +6485,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4126
6485
  // comparePhaseNum-based so `02` and `2` dedupe. With no ROADMAP.md (or no
4127
6486
  // parseable rows) the set is empty and the scans behave exactly as
4128
6487
  // before.
6488
+ // Custom IDs use declared roadmap order; the numeric-minimum comments
6489
+ // below describe the unchanged legacy cascade.
4129
6490
  const roadmapCompleteNums = [];
4130
6491
  if (roadmapContent !== null) {
4131
6492
  try {
4132
6493
  const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
4133
- const completePattern = new RegExp(`-\\s*\\[[xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi');
6494
+ const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})`, 'gi');
4134
6495
  let cm;
4135
6496
  while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
6497
+ if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(cm[0]))
6498
+ continue;
4136
6499
  if (isSentinelPhaseId(cm[1]))
4137
6500
  continue;
4138
- if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n) === 0)) {
6501
+ if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n, directoryContext) === 0)) {
4139
6502
  roadmapCompleteNums.push(cm[1]);
4140
6503
  }
4141
6504
  }
@@ -4145,7 +6508,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4145
6508
  * set empty — the scans then behave exactly as they did pre-#4699. */
4146
6509
  }
4147
6510
  }
4148
- const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n) === 0);
6511
+ const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n, directoryContext) === 0);
4149
6512
  try {
4150
6513
  // #3185 (ADR-3180 Decision 1): "which phase directories belong to
4151
6514
  // the CURRENT milestone" — routed through the canonical owner
@@ -4156,7 +6519,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4156
6519
  // is not necessarily identical to the raw directory name).
4157
6520
  const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
4158
6521
  for (const dir of dirs) {
4159
- const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
6522
+ const custom = phaseIdMod.customPhaseQuery(dir, directoryContext);
6523
+ const owner = custom && customDirectoryOwner(dir, custom);
6524
+ const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${phaseNumberTokenSource(phaseConvention)})-?(.*)`, 'i'));
4160
6525
  if (dm) {
4161
6526
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
4162
6527
  if (isSentinelPhaseId(dm[1]))
@@ -4170,8 +6535,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4170
6535
  // correct today — but that makes this scan's correctness depend on an
4171
6536
  // upstream sort nothing here states. Selecting the minimum explicitly costs
4172
6537
  // one comparison and removes the hidden coupling.
4173
- if (comparePhaseNum(dm[1], phaseNum) > 0
4174
- && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
6538
+ if (comparePhaseNum(dm[1], phaseNum, directoryContext) > 0
6539
+ && (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum, directoryContext) < 0)) {
4175
6540
  diskNextNum = dm[1];
4176
6541
  diskNextName = dm[2] || null;
4177
6542
  }
@@ -4219,8 +6584,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4219
6584
  // checkbox branch widens, and only to the separators #2199 already
4220
6585
  // accepts. The two branches keep separate capture groups, normalized
4221
6586
  // just below the loop.
4222
- const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
4223
- `|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gi');
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');
4224
6589
  let pm;
4225
6590
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
4226
6591
  // #4078: normalize the two alternation branches' captures (heading
@@ -4251,8 +6616,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4251
6616
  // Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
4252
6617
  // the disk scan and for #2028's lowest-outstanding override; the roadmap
4253
6618
  // defines which phases EXIST and which milestone they belong to.
4254
- if (comparePhaseNum(pmNum, phaseNum) > 0
4255
- && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
6619
+ if (comparePhaseNum(pmNum, phaseNum, directoryContext) > 0
6620
+ && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum, directoryContext) < 0)) {
4256
6621
  roadmapNextNum = pmNum;
4257
6622
  roadmapNextName = pmName
4258
6623
  .replace(/\(INSERTED\)/i, '')
@@ -4276,7 +6641,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4276
6641
  // and the roadmap mean the same phase" cannot drift from "N is above the
4277
6642
  // one just completed". `02` and `2` compare equal, which is the whole
4278
6643
  // point — they are the same phase spelled two ways.
4279
- const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
6644
+ const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum, directoryContext) === 0;
4280
6645
  nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
4281
6646
  nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
4282
6647
  isLastPhase = false;
@@ -4320,11 +6685,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4320
6685
  // Without it, this lowest-outstanding override was blind to dash-grammar
4321
6686
  // rows and could not correct an out-of-order completion on the same
4322
6687
  // mixed-grammar roadmaps that broke stage 2.
4323
- const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi');
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' });
4324
6689
  let cbm;
4325
6690
  let lowestOutstanding = null;
4326
6691
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
4327
- const isChecked = cbm[1].toLowerCase() === 'x';
6692
+ const isChecked = (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(cbm[1]);
4328
6693
  // #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
4329
6694
  // comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
4330
6695
  // backlog row sorts below every real phase and is wrongly selected as next_phase,
@@ -4332,8 +6697,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4332
6697
  // isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
4333
6698
  // real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
4334
6699
  // still selected, preserving #2028's out-of-order-completion behavior.
4335
- if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
4336
- if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
6700
+ if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum, directoryContext) < 0) {
6701
+ if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num, directoryContext) < 0) {
4337
6702
  lowestOutstanding = {
4338
6703
  num: cbm[2],
4339
6704
  name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
@@ -4362,14 +6727,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4362
6727
  // derivation to that milestone's Progress-table rows.
4363
6728
  const stateFm = frontmatterMod.extractFrontmatter(originalStateContent, statePath);
4364
6729
  const storedMilestone = roadmapParserMod.getMilestoneInfo(cwd).value?.version
4365
- ?? milestoneVersionOf(stateFm['milestone']);
6730
+ ?? (0, roadmap_progress_cjs_1.milestoneVersionOf)(stateFm['milestone']);
4366
6731
  // #5165: re-completing a phase that is already complete in ROADMAP.md
4367
6732
  // while STATE has moved past it is a no-op for STATE's position —
4368
6733
  // the transition, the metrics row and the authoritative current-phase
4369
6734
  // pin would all rewind it. A first-time completion (box unchecked, or
4370
6735
  // STATE not past N, #2028 out-of-order) still transitions.
4371
6736
  const stateAlreadyPast = phaseWasAlreadyComplete
4372
- && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
6737
+ && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath, phaseConvention);
4373
6738
  let stateContent = originalStateContent;
4374
6739
  // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
4375
6740
  // shape/name, Status, Current Plan, Last Activity + Description, and
@@ -4396,10 +6761,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4396
6761
  let nextPhaseDisk = null;
4397
6762
  if (nextPhaseNum && !isLastPhase) {
4398
6763
  try {
4399
- const nextInfo = findPhaseInternal(cwd, nextPhaseNum);
6764
+ const nextInfo = findPhaseInternal(cwd, nextPhaseNum, undefined, directoryContext);
4400
6765
  if (nextInfo && nextInfo.found && nextInfo.directory) {
4401
6766
  const { value } = (0, phase_status_cjs_1.phaseStatus)(node_path_1.default.join(cwd, nextInfo.directory), {
4402
- convention: resolvePhaseIdConvention(cwd),
6767
+ convention: phaseConvention,
4403
6768
  });
4404
6769
  nextPhaseDisk = { status: value.status, planCount: value.planCount, summaryCount: value.summaryCount };
4405
6770
  }
@@ -4617,7 +6982,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
4617
6982
  if (!phaseNum) {
4618
6983
  error('phase number required for phase uat-passed');
4619
6984
  }
4620
- const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
6985
+ const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
4621
6986
  if (!phaseInfoRaw) {
4622
6987
  error(`Phase ${phaseNum} not found`);
4623
6988
  }
@@ -4639,7 +7004,7 @@ function cmdPhaseListPlans(cwd, phaseNum, raw) {
4639
7004
  if (!phaseNum) {
4640
7005
  error('phase number required for phase list-plans');
4641
7006
  }
4642
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
7007
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
4643
7008
  if (!phaseInfo) {
4644
7009
  output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
4645
7010
  return;
@@ -4677,4 +7042,12 @@ module.exports = {
4677
7042
  computeDependencyLevels,
4678
7043
  buildShortFormToId,
4679
7044
  normalizeDependencyToken,
7045
+ // Test seam (#4304): the bracket renumber mapping is a disk and ROADMAP
7046
+ // bijection whose order must keep every rename destination free; exposed
7047
+ // so a property test can drive it over generated identity sets.
7048
+ _computeBracketRenumberMapping: computeBracketRenumberMapping,
7049
+ // Test seam (#4304): the qualified-reference rewriter the ROADMAP rewrite
7050
+ // applies once per mapping entry, so the same property can rewrite a line
7051
+ // in mapping order.
7052
+ _replaceQualifiedBracketReference: replaceQualifiedBracketReference,
4680
7053
  };