@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
@@ -1,14 +1,24 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Pure phase-id parsing/matching helpers — normalize, token match,
4
- * milestone/phase-dir id parsing, phase-markdown regex builders.
4
+ * milestone/phase-dir id parsing, phase-markdown regex builders, and identity
5
+ * indexes over caller-supplied roadmap text. No filesystem or config reads.
5
6
  *
6
7
  * Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
7
8
  * The hand-written bodies are preserved byte-for-behaviour; only the module
8
9
  * boundary moved. The core.cjs re-export spine was retired in epic #1267;
9
10
  * callers import phase-id helpers from phase-id.cjs directly.
10
11
  *
12
+ * Custom directory lookup decision table (#5186):
13
+ * 1. An exact physical basename selects that directory and reports its owner.
14
+ * 2. Exact recorded IDs win; a unique case/padding alias may name a record.
15
+ * Multiple alias records remain ambiguous rather than last-write-wins.
16
+ * 3. Markerless reads use known heading/ID prefixes; mutations refuse when
17
+ * more than one owner fits. Marked segment-prefix pairs are independent.
18
+ * 4. Unrecorded numeric and bracket queries keep their legacy matching paths.
19
+ *
11
20
  * Dependencies:
21
+ * - ./markdown-sectionizer.cjs (tokenizeHeadings over supplied roadmap text)
12
22
  * - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
13
23
  * longer the owner of pattern-escaping, only a consumer)
14
24
  * - ./core-utils.cjs (generateSlugInternal — #3883/ADR-3473 §8.3: the
@@ -23,7 +33,9 @@
23
33
  * body) instead, mirroring the same cycle-break already used by
24
34
  * core-utils.cts's own getPhaseFileStats/plan-scan.cjs seam.
25
35
  */
36
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
26
37
  const pattern_cjs_1 = require("./pattern.cjs");
38
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
27
39
  // ─── Phase-id helpers ─────────────────────────────────────────────────────────
28
40
  // project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
29
41
  // leading underscores are not valid project codes per .planning/config.json.
@@ -59,7 +71,9 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
59
71
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
60
72
  // introduced outside this module without a `// phase-id-owner:` justification.
61
73
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
62
- // #4764: a phase REFERENCE in depends-on PROSE — the token in context, directly
74
+ // #5676: milestone-prefixed M-NN token grammar (one optional -NN segment, the #5626 grammar).
75
+ const PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED = '\\d+(?:-\\d+)?[A-Z]?(?:\\.\\d+)*';
76
+ // #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
63
77
  // following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
64
78
  // "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
65
79
  // anchored on, because two readers consume Depends-on prose (init.manager's
@@ -72,7 +86,8 @@ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
72
86
  // dependencies. The `-` separator deliberately extracts range ENDPOINTS only
73
87
  // ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
74
88
  // out (a range's middle is not written as a reference).
75
- const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*)`;
89
+ const PHASE_DEP_TOKEN_LIST_SOURCE = `${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*`;
90
+ const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_DEP_TOKEN_LIST_SOURCE})`;
76
91
  // #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
77
92
  // sites that scan directory names (where a project code or a variant suffix may
78
93
  // legitimately be lowercase) and therefore cannot use a case-sensitive class.
@@ -303,6 +318,43 @@ const PHASE_HEADING_BASELINE = Object.freeze({
303
318
  *
304
319
  * Pure: takes the resolved convention, never reads config.
305
320
  */
321
+ // #4304: `bracketAlt`'s own alternation, factored out to a named
322
+ // function so every write-side consumer that needs "this bracket, optionally
323
+ // followed by the literal Phase label, then a digit" derives from the SAME
324
+ // expression the read grammar compiles — never a hand-retyped `[ \t]+` /
325
+ // case-sensitive copy. `idSrc` is the regex SOURCE for the bracket's
326
+ // `{CODE}.{MM}` interior: the generic `BRACKET_ID_SRC` class (capturing or
327
+ // not) for a classifier that must recognize ANY bracket identity, or one
328
+ // escaped, already-known project/milestone pair (via
329
+ // `bracketQualifiedIntroSrcFor` below) for a rewriter/detector that already
330
+ // knows exactly which identity it means. Returns only the regex SOURCE —
331
+ // every caller compiles it themselves (every reader uses the `i` flag), so
332
+ // this composes with `g`, an outer capturing group, or a trailing literal
333
+ // number.
334
+ function bracketAltIntroSrcFor(idSrc) {
335
+ // `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
336
+ // followed by a blank line and a digit-leading prose line read as one phase.
337
+ return `\\[${idSrc}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
338
+ }
339
+ /**
340
+ * #4304: the qualified-mention intro for ONE already-known
341
+ * bracket identity — `[{PROJECT}.{MM}]` (both escaped literals, not a
342
+ * class), optionally followed by the `Phase` label — built from
343
+ * `bracketAltIntroSrcFor` so a rewriter or detector anchoring a specific
344
+ * phase-number token right after it accepts exactly the spellings
345
+ * `phaseHeadingPrefixSrcFor`'s bracket alternative does: any case (compile
346
+ * with `i`), zero-or-more spaces, an optional `Phase` label. phase.cts's
347
+ * write-side bracket-identity regexes (the qualified-reference replacer, the
348
+ * qualified-mention detector) build their intro through this instead of
349
+ * re-typing `[ \t]+` / case-sensitive brackets independently of the read
350
+ * grammar. A hand-composed case-sensitive, `[ \t]+`-spaced copy would reject
351
+ * `[ck.02] 02:`, `[CK.02] PHASE 02:`, and the no-space `[CK.02]02:`, all of
352
+ * which `roadmap get-phase`, `roadmap analyze`, `phase insert` and
353
+ * `phase add` accept as real phases.
354
+ */
355
+ function bracketQualifiedIntroSrcFor(project, milestone) {
356
+ return bracketAltIntroSrcFor(`${(0, pattern_cjs_1.escapeRegex)(String(project))}\\.${(0, pattern_cjs_1.escapeRegex)(String(milestone))}`);
357
+ }
306
358
  function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
307
359
  const base = baseline === PHASE_HEADING_BASELINE.ANY_BRACKET
308
360
  ? BASE_ANY_BRACKET_HEADING_PREFIX_SRC
@@ -310,8 +362,6 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
310
362
  if (convention !== 'bracket')
311
363
  return base;
312
364
  const id = capturing ? `(${BRACKET_ID_SRC})` : BRACKET_ID_SRC;
313
- // `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
314
- // followed by a blank line and a digit-leading prose line read as one phase.
315
365
  // BOTH bracket forms are admitted at both baselines, and both CAPTURE. The
316
366
  // any-bracket base already matches `[GSD.999] Phase 07:` on its own — but
317
367
  // through the base alternative, which captures nothing, so the reader saw
@@ -319,9 +369,31 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
319
369
  // counted a labeled icebox heading as a real phase while the label-less form
320
370
  // beside it was excluded. Two derivations of one ROADMAP disagreed. The
321
371
  // bracket alternative is tried FIRST so it wins the capture.
322
- const bracketAlt = `\\[${id}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
372
+ const bracketAlt = bracketAltIntroSrcFor(id);
323
373
  return `(?:${bracketAlt}|${base})`;
324
374
  }
375
+ /**
376
+ * Parse one roadmap phase-checkbox row through the same anchored grammar used
377
+ * by readers and writers (#4982). The identity must be the first thing after
378
+ * the checkbox, tolerating only an optional `**`. Bracket mode captures the
379
+ * leading `[CODE.MM] PP` identity. Both paths treat whitespace or a colon
380
+ * immediately after the phase token as the boundary. Returning semantic fields
381
+ * keeps consumers from depending on capture-group offsets.
382
+ */
383
+ function parsePhaseChecklistLine(line, convention, context) {
384
+ const capturesBracketId = convention === 'bracket';
385
+ const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
386
+ const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})`
387
+ + `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
388
+ const match = pattern.exec(line);
389
+ if (!match)
390
+ return null;
391
+ return {
392
+ checked: (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(match[1]),
393
+ bracketId: capturesBracketId ? match[2] : undefined,
394
+ phaseToken: capturesBracketId ? match[3] : match[2],
395
+ };
396
+ }
325
397
  /**
326
398
  * The single owner of the "scan a whole document for every phase heading"
327
399
  * pattern (#4865 / ADR-4910 §8) — anchored (`^ {0,3}#{2,4}`, so indentation up
@@ -339,13 +411,56 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
339
411
  * This function answers "which phases exist in this content", not "does this
340
412
  * specific phase exist".
341
413
  */
342
- function buildPhaseHeadingScanRegex(baseline, convention) {
414
+ function buildPhaseHeadingScanRegex(baseline, convention, context) {
343
415
  const bracketGroup = convention === 'bracket' ? 1 : null;
344
416
  const phaseNumGroup = bracketGroup ? 2 : 1;
345
417
  const phaseNameGroup = phaseNumGroup + 1;
346
- const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
418
+ const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
347
419
  return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
348
420
  }
421
+ /**
422
+ * Every scan that answers "which phase numbers does this roadmap text declare" (#5528): section
423
+ * headings, plus checklist bullets when `withBullets`. A non-bracket convention gets the plain
424
+ * `Phase N` heading scan and the checkbox-led `Phase N` bullet it always had.
425
+ *
426
+ * Under the bracket convention it also gets, beyond `buildPhaseHeadingScanRegex`'s `[ID] NN` and
427
+ * `[ID] Phase NN` headings: the word-first heading `Phase [ID] NN`; and a bullet whose bracket IS
428
+ * the id (`- [GSD.02] 03: X`, no checkbox). The checkbox of the other bullet shape may not itself
429
+ * be a bracket id, or `- [GSD.999] Phase 07` would read `[GSD.999]` as a checkbox and drop the id
430
+ * that marks it an icebox row. A bullet is one line, so its label uses `[ \t]` where a heading's
431
+ * uses `\s`.
432
+ */
433
+ function buildPhaseDeclarationScans(convention, withBullets = true) {
434
+ const heading = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
435
+ const scans = [{ regex: heading.regex, idGroup: heading.bracketGroup, numGroup: heading.phaseNumGroup }];
436
+ const bracket = convention === 'bracket';
437
+ // Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
438
+ const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
439
+ // Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
440
+ const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : convention === 'milestone-prefixed' ? `(${phaseNumberTokenSource(convention)})(?=[:.\\s*]|$)` : '(\\d+)(?=[:.\\s*]|$)';
441
+ const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
442
+ if (!bracket) {
443
+ if (withBullets)
444
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+${tail}`, 'gim'), idGroup: null, numGroup: 1 });
445
+ return scans;
446
+ }
447
+ scans.push({ regex: new RegExp(`^ {0,3}#{2,4}\\s*Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gim'), idGroup: 1, numGroup: 2 });
448
+ if (withBullets) {
449
+ const label = `(?:\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))|Phase[ \\t]+)`;
450
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}${label}${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
451
+ // The word-first heading's bullet twin: `- [ ] Phase [GSD.02] 07:`.
452
+ scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
453
+ // The checkbox forms above deliberately over-count a colon-less `- [ ] [GSD.02] 2026 notes` as phase 2026: a
454
+ // skipped number is harmless, a reused one is not, so do not "fix" them with a colon requirement.
455
+ // With no checkbox to vouch for it the number needs a `:`, a bold close or end of line, or a dash
456
+ // separator (`- [GSD.02] 05 - Name`), or `- [GSD.02] 2026 notes` is phase 2026.
457
+ scans.push({ regex: new RegExp(`${bullet}\\*{0,2}\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))(\\d+)(?=[A-Za-z]?(?::|\\*{0,2}[ \\t]*$|[ \\t]+[-\u2013\u2014][ \\t]))`, 'gim'), idGroup: 1, numGroup: 2 });
458
+ }
459
+ // A dash-separated heading (`### [GSD.02] 05 - Name`) is outside the colon grammar the readers share, but still a declaration.
460
+ // `###`/`####` only: a `##` milestone heading (`## [CK.02] 2 - Foundation`) names the milestone, not a phase.
461
+ scans.push({ regex: new RegExp(`^ {0,3}#{3,4}\\s*\\[(${BRACKET_ID_SRC})\\][ \\t]*(\\d+)(?=[A-Za-z]?[ \\t]+[-\u2013\u2014][ \\t])`, 'gim'), idGroup: 1, numGroup: 2 });
462
+ return scans;
463
+ }
349
464
  function stripProjectCodePrefix(value, caseInsensitive = true) {
350
465
  const input = String(value);
351
466
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -489,6 +604,116 @@ function parsePhaseId(input) {
489
604
  // tokens unchanged.
490
605
  throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
491
606
  }
607
+ /**
608
+ * Tokenize dependency references with source spans. Bracket-qualified lists
609
+ * canonicalize every numeric segment before strict identity parsing. If the
610
+ * qualified identity still cannot be parsed, the reader keeps the original
611
+ * token as a legacy dependency instead of silently dropping it.
612
+ */
613
+ function tokenizePhaseDependencyReferences(prose, convention) {
614
+ const input = prose;
615
+ const found = [];
616
+ const qualifiedTokenSpans = [];
617
+ const legacyRefRe = new RegExp(`${PHASE_DEP_REF_SOURCE}`, 'gi');
618
+ const tokenRe = new RegExp(PHASE_NUMBER_TOKEN_SOURCE, convention === 'bracket' ? 'gi' : 'g');
619
+ let refMatch;
620
+ while ((refMatch = legacyRefRe.exec(input)) !== null) {
621
+ const referenceStart = refMatch.index + refMatch[0].length - refMatch[1].length;
622
+ tokenRe.lastIndex = 0;
623
+ let tokenMatch;
624
+ while ((tokenMatch = tokenRe.exec(refMatch[1])) !== null) {
625
+ const start = referenceStart + tokenMatch.index;
626
+ found.push({ kind: 'legacy', start, end: start + tokenMatch[0].length, token: tokenMatch[0] });
627
+ }
628
+ }
629
+ if (convention !== 'bracket') {
630
+ return found;
631
+ }
632
+ const bracketDisplayRe = new RegExp(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true)}(${PHASE_DEP_TOKEN_LIST_SOURCE})`, 'gi');
633
+ let displayMatch;
634
+ while ((displayMatch = bracketDisplayRe.exec(input)) !== null) {
635
+ if (!displayMatch[1])
636
+ continue;
637
+ const tokenList = displayMatch[2];
638
+ const tokenListStart = displayMatch.index + displayMatch[0].length - tokenList.length;
639
+ tokenRe.lastIndex = 0;
640
+ let tokenMatch;
641
+ while ((tokenMatch = tokenRe.exec(tokenList)) !== null) {
642
+ const start = tokenListStart + tokenMatch.index;
643
+ const end = start + tokenMatch[0].length;
644
+ qualifiedTokenSpans.push({ start, end });
645
+ const numeric = tokenMatch[0].split('.').every((part) => /^\d+$/.test(part))
646
+ ? tokenMatch[0].split('.').map(pad2).join('.')
647
+ : null;
648
+ try {
649
+ if (numeric === null)
650
+ throw new Error('not a numeric bracket phase token');
651
+ const id = parsePhaseId(`[${foldBracketId(displayMatch[1])}] ${numeric}`);
652
+ found.push({
653
+ kind: 'qualified',
654
+ referenceStart: displayMatch.index,
655
+ start,
656
+ end,
657
+ token: renderPhaseId(id),
658
+ });
659
+ }
660
+ catch {
661
+ found.push({
662
+ kind: 'qualified-fallback',
663
+ referenceStart: displayMatch.index,
664
+ start,
665
+ end,
666
+ token: tokenMatch[0],
667
+ });
668
+ }
669
+ }
670
+ }
671
+ const chunks = input.matchAll(/\S+/g);
672
+ for (const chunk of chunks) {
673
+ const candidate = chunk[0]
674
+ .replace(/^[`*_([{]+/, '')
675
+ .replace(/[`*_\])}.:,;]+$/, '');
676
+ try {
677
+ const id = parsePhaseId(candidate);
678
+ const sub = id.subphase ? `.${id.subphase}` : '';
679
+ const canonicalDash = `${id.project}.${id.milestone}-${id.phase}${sub}`;
680
+ if (id.plan || candidate !== canonicalDash)
681
+ continue;
682
+ found.push({
683
+ kind: 'identity',
684
+ start: chunk.index ?? 0,
685
+ end: (chunk.index ?? 0) + candidate.length,
686
+ token: renderPhaseId(id),
687
+ });
688
+ }
689
+ catch {
690
+ // Most prose chunks are not identities. parsePhaseId is the classifier.
691
+ }
692
+ }
693
+ const trimmed = input.trim();
694
+ if (new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i').test(trimmed)) {
695
+ const start = input.indexOf(trimmed);
696
+ found.push({ kind: 'bare', start, end: start + trimmed.length, token: trimmed });
697
+ }
698
+ const identityAware = found.filter(({ kind, start, end }) => {
699
+ if (kind !== 'legacy')
700
+ return true;
701
+ return !qualifiedTokenSpans.some((span) => start === span.start && end === span.end);
702
+ });
703
+ identityAware.sort((a, b) => a.start - b.start);
704
+ return identityAware;
705
+ }
706
+ function extractPhaseDependencyTokens(prose, convention) {
707
+ const found = tokenizePhaseDependencyReferences(prose, convention);
708
+ const seen = new Set();
709
+ return found.flatMap(({ token }) => {
710
+ const key = token.toUpperCase();
711
+ if (seen.has(key))
712
+ return [];
713
+ seen.add(key);
714
+ return [token];
715
+ });
716
+ }
492
717
  function renderMilestoneId(id) {
493
718
  return `[${id.project}.${id.milestone}]`;
494
719
  }
@@ -660,7 +885,10 @@ function isSentinelPhaseDir(dirName) {
660
885
  * Render a regex source fragment matching a phase number against ROADMAP/STATE
661
886
  * prose regardless of zero-padding on either side.
662
887
  */
663
- function phaseMarkdownRegexSource(phaseNum) {
888
+ function phaseMarkdownRegexSource(phaseNum, context) {
889
+ const custom = context && customPhaseQuery(String(phaseNum), context);
890
+ if (custom)
891
+ return (0, pattern_cjs_1.escapeRegex)(custom.id);
664
892
  const stripped = stripProjectCodePrefix(phaseNum);
665
893
  // Milestone-prefixed IDs: M-NN or M-N-N (deep).
666
894
  const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
@@ -698,7 +926,33 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
698
926
  return null;
699
927
  return (0, pattern_cjs_1.escapeRegex)(raw);
700
928
  }
701
- function comparePhaseNum(a, b) {
929
+ function comparePhaseNum(a, b, context) {
930
+ if (context?.phaseNaming === 'custom') {
931
+ const index = customIdentityIndex(context);
932
+ const identity = (value) => {
933
+ const text = String(value);
934
+ if (index.recorded.has(text))
935
+ return text;
936
+ const query = customPhaseQuery(text, context);
937
+ return query?.recordedId ?? (query ? (query.listed ? query.id : customDirectoryOwner(text, query)?.id ?? query.id) : text);
938
+ };
939
+ const first = identity(a), second = identity(b);
940
+ // Arbitrary custom IDs have no numeric ordering: use their declared order.
941
+ // The relation must stay a total preorder for every `.sort` it feeds, so
942
+ // once a project declares a custom ID every declared ID sorts by position
943
+ // and ahead of every undeclared one; undeclared IDs (including unlisted
944
+ // numeric directories) keep the original comparator among themselves.
945
+ // A project with only legacy numeric IDs never enters this branch.
946
+ if (index.hasCustom) {
947
+ const left = index.orderByFold.get(recordedPhaseKey(first)), right = index.orderByFold.get(recordedPhaseKey(second));
948
+ if (left !== undefined && right !== undefined)
949
+ return left - right;
950
+ if (left !== undefined)
951
+ return -1;
952
+ if (right !== undefined)
953
+ return 1;
954
+ }
955
+ }
702
956
  // Strip optional project_code prefix before comparing
703
957
  const sa = stripProjectCodePrefix(a);
704
958
  const sb = stripProjectCodePrefix(b);
@@ -1292,6 +1546,231 @@ const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`);
1292
1546
  const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`);
1293
1547
  /** Strip leading zeros for numeric-equality compare, keeping a lone "0". */
1294
1548
  const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1549
+ /** #5186: the directory shape shared by phase.add and custom-ID readers. */
1550
+ const UNSAFE_PHASE_DIRECTORY_PART_RE = /[/\\\0]|\.\./;
1551
+ class PhaseDirectoryNameError extends Error {
1552
+ kind;
1553
+ constructor(message, kind = 'invalid') {
1554
+ super(message);
1555
+ this.kind = kind;
1556
+ }
1557
+ }
1558
+ function phaseDirectoryName(id, slug, projectCode) {
1559
+ if (!id || [id, slug, projectCode ?? ''].some(part => UNSAFE_PHASE_DIRECTORY_PART_RE.test(part))) {
1560
+ throw new PhaseDirectoryNameError('Invalid phase id or directory component: paths, .. and NUL are not allowed');
1561
+ }
1562
+ return `${projectCode ? `${projectCode}-` : ''}${id}-${slug}`;
1563
+ }
1564
+ /** Grammar required only when writing an arbitrary custom identity. */
1565
+ function validateCustomPhaseId(id) {
1566
+ if (!id || id !== id.trim() || /[\s:]/.test(id) || id.startsWith('[')) {
1567
+ throw new PhaseDirectoryNameError('Invalid custom phase id: empty IDs, whitespace, colons and a leading [ are not allowed');
1568
+ }
1569
+ }
1570
+ /** Keep numeric, letter-suffixed and bracket queries on their legacy path. */
1571
+ const LEGACY_PHASE_ID_RE = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}(?:-\\d+)*$`, 'i');
1572
+ function isCustomPhaseId(id) {
1573
+ return !LEGACY_PHASE_ID_RE.test(id)
1574
+ && !id.startsWith('[');
1575
+ }
1576
+ /** Preserve every legacy segment while applying its normal padding rules. */
1577
+ function recordedPhaseKey(id) {
1578
+ // Whole-token normalization can discard a letter-suffixed continuation
1579
+ // (2A-2). Normalize its segments independently so it never aliases 2A.
1580
+ return (LEGACY_PHASE_ID_RE.test(id) ? id.split('-').map(normalizePhaseName).join('-') : id).toLowerCase();
1581
+ }
1582
+ const CUSTOM_PHASE_ID_FILE = '.phase-id';
1583
+ /** Naming policy is supplied by the config owner; grammar remains pure. */
1584
+ function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
1585
+ return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
1586
+ }
1587
+ /** #5676: the single owner of the numeric phase-token source per convention. */
1588
+ function phaseNumberTokenSource(convention) {
1589
+ return convention === 'milestone-prefixed' ? PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED : PHASE_NUMBER_TOKEN_SOURCE;
1590
+ }
1591
+ /**
1592
+ * Custom scans reuse the numeric source verbatim outside configured custom projects.
1593
+ * #5676: under `milestone-prefixed` the numeric source admits the `M-NN` token
1594
+ * (`3-02`), so a `03-02-beta` directory is read as phase `3-02`, not phase `3`.
1595
+ */
1596
+ function phaseScanTokenSource(context, convention) {
1597
+ return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : phaseNumberTokenSource(convention);
1598
+ }
1599
+ const customIdentityIndexes = new WeakMap();
1600
+ function customIdentityIndex(options) {
1601
+ let index = customIdentityIndexes.get(options);
1602
+ if (!index || index.roadmap !== options.roadmap || index.knownIds !== options.knownIds) {
1603
+ const headingId = /^Phase[ \t]+([^ \t:\n]+)(?:[ \t]+\([^)\n]*\))?[ \t]*:/;
1604
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(options.roadmap ?? '')
1605
+ .filter(heading => heading.level >= 2 && heading.level <= 4)
1606
+ .flatMap(heading => heading.text.match(headingId)?.[1] ?? []);
1607
+ const recorded = new Set(Object.values(options.directoryIds ?? {}));
1608
+ const recordedByFold = new Map();
1609
+ for (const id of recorded) {
1610
+ const key = recordedPhaseKey(id);
1611
+ const aliases = recordedByFold.get(key);
1612
+ if (aliases)
1613
+ aliases.push(id);
1614
+ else
1615
+ recordedByFold.set(key, [id]);
1616
+ }
1617
+ const ids = new Set([...headings, ...options.knownIds ?? [], ...recorded]);
1618
+ index = {
1619
+ roadmap: options.roadmap, knownIds: options.knownIds, explicitIds: new Set(options.knownIds ?? []), ids, recorded, recordedByFold, order: new Map([...ids].map((id, i) => [id, i])),
1620
+ // Declared position under the same fold the writers use, so `02` and `2` share a slot.
1621
+ orderByFold: [...ids].reduce((fold, id, i) => fold.has(recordedPhaseKey(id)) ? fold : fold.set(recordedPhaseKey(id), i), new Map()),
1622
+ hasCustom: recorded.size > 0 || [...ids].some(isCustomPhaseId),
1623
+ prefixes: [...ids].filter(token => token && !UNSAFE_PHASE_DIRECTORY_PART_RE.test(token))
1624
+ .sort((a, b) => b.length - a.length)
1625
+ .map(token => ({ id: token, prefix: phaseDirectoryName(token, '') })),
1626
+ };
1627
+ customIdentityIndexes.set(options, index);
1628
+ }
1629
+ return index;
1630
+ }
1631
+ /** Preflight every custom writer, including all IDs in a batch, before writes. */
1632
+ function validateCustomPhaseIds(ids, context) {
1633
+ const index = customIdentityIndex(context);
1634
+ const known = new Set(index.ids);
1635
+ const prospective = { ...context, knownIds: [...context.knownIds ?? [], ...ids] };
1636
+ for (const id of ids) {
1637
+ validateCustomPhaseId(id);
1638
+ // The same sentinel grammar filters phases list and roadmap.analyze.
1639
+ // Refuse identities those readers would hide rather than creating orphans.
1640
+ if (isSentinelPhaseId(id)) {
1641
+ throw new PhaseDirectoryNameError(`Custom ID ${id} is reserved for backlog or icebox phases and would be hidden by phase readers; choose a visible ID`, 'collision');
1642
+ }
1643
+ if (context.projectCode && id.toLowerCase().startsWith(`${context.projectCode}-`.toLowerCase())) {
1644
+ throw new PhaseDirectoryNameError(`Custom ID ${id} already begins with project_code ${context.projectCode}; pass the ID without its project prefix`);
1645
+ }
1646
+ const collision = [...known].find(other => recordedPhaseKey(id) === recordedPhaseKey(other));
1647
+ if (collision) {
1648
+ throw new PhaseDirectoryNameError(`Custom ID ${id} collides with existing or pending ID ${collision}; choose a distinct ID`, 'collision');
1649
+ }
1650
+ for (const dir of context.unmarkedDirectories ?? []) {
1651
+ const candidates = customDirectoryCandidates(dir, prospective);
1652
+ if (candidates.some(candidate => recordedPhaseKey(candidate) === recordedPhaseKey(id))) {
1653
+ throw new PhaseDirectoryNameError(`Custom ID ${id} would claim markerless directory ${dir}; candidates: ${candidates.join(', ')}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, 'collision');
1654
+ }
1655
+ }
1656
+ known.add(id);
1657
+ }
1658
+ }
1659
+ /** Custom heading IDs are identity hints only; titles never locate directories. */
1660
+ function customPhaseQuery(id, options) {
1661
+ if (options?.phaseNaming !== 'custom' || id.startsWith('['))
1662
+ return null;
1663
+ if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(id))
1664
+ return null;
1665
+ const index = customIdentityIndex(options);
1666
+ const prefix = options.projectCode ? `${options.projectCode}-` : '';
1667
+ const queryBody = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
1668
+ // Markers are authoritative; only legacy-shaped IDs share their ordinary
1669
+ // numeric padding normalization. Resolve recorded IDs before interpreting a
1670
+ // query as a basename: an ID may equal a different directory's full name.
1671
+ const aliases = index.recordedByFold.get(recordedPhaseKey(queryBody)) ?? [];
1672
+ let recorded = index.recorded.has(id) ? id : index.recorded.has(queryBody) ? queryBody : aliases.length === 1 ? aliases[0] : undefined;
1673
+ if (recorded !== undefined) {
1674
+ id = recorded;
1675
+ }
1676
+ else {
1677
+ const directoryId = options.directoryIds?.[id]
1678
+ ?? (options.unmarkedDirectories?.includes(id) ? customDirectoryOwner(id, {
1679
+ id, listed: false, projectCode: options.projectCode, prefixes: index.prefixes,
1680
+ })?.id : undefined);
1681
+ if (directoryId !== undefined) {
1682
+ id = directoryId;
1683
+ if (index.recorded.has(id))
1684
+ recorded = id;
1685
+ }
1686
+ }
1687
+ const body = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
1688
+ const listed = index.ids.has(id);
1689
+ // Legacy numeric tokens (including configured display prefixes) keep their
1690
+ // reading unless a writer explicitly recorded this exact ID. That record
1691
+ // also distinguishes an unpadded 2A from the custom continuation 2A-2.
1692
+ if (!index.recorded.has(id) && !body.includes('-') && LEGACY_PHASE_ID_RE.test(body))
1693
+ return null;
1694
+ if (!index.recorded.has(id) && !isCustomPhaseId(id) && !(listed && /[a-z]/i.test(id)))
1695
+ return null;
1696
+ // Only a complete legacy segment identifies a numeric directory. Digit-leading
1697
+ // custom IDs such as 2FA must not collapse to their leading digit run.
1698
+ if (!listed && !options.directoryIds?.[id] && body.includes('-') && LEGACY_PHASE_ID_RE.test(body.split('-')[0]))
1699
+ return null;
1700
+ // Listed identities outrank the query; a directory's slug never invents an ID.
1701
+ return {
1702
+ id, listed: listed, recordedId: recorded, projectCode: options.projectCode, directoryIds: options.directoryIds,
1703
+ prefixes: listed ? index.prefixes : [...index.prefixes, { id, prefix: phaseDirectoryName(id, '') }],
1704
+ };
1705
+ }
1706
+ /** Candidate project prefixes for the custom directory shape. */
1707
+ function customDirectoryBodies(dir, projectCode) {
1708
+ const bodies = [dir];
1709
+ if (projectCode) {
1710
+ const prefix = `${projectCode}-`;
1711
+ if (dir.startsWith(prefix))
1712
+ bodies.unshift(dir.slice(prefix.length));
1713
+ }
1714
+ return bodies;
1715
+ }
1716
+ /** Known IDs and writer-shaped prefixes are computed once per query. */
1717
+ function customDirectoryOwner(dir, query) {
1718
+ // A writer-recorded identity survives heading edits and archival. A slug
1719
+ // cannot prove where an arbitrary hyphenated ID ends. Legacy directories
1720
+ // keep heading-assisted read compatibility; destructive callers require proof.
1721
+ const recordedId = query.directoryIds?.[dir];
1722
+ if (recordedId !== undefined) {
1723
+ if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(recordedId))
1724
+ return null;
1725
+ const prefixes = [phaseDirectoryName(recordedId, '')];
1726
+ // Scaffold retains legacy numeric directory padding while recording the
1727
+ // requested ID. Resolve its directory shape independently of the query
1728
+ // normalization above; a digit-leading custom ID still owns its full token.
1729
+ if (LEGACY_PHASE_ID_RE.test(recordedId))
1730
+ prefixes.push(phaseDirectoryName(normalizePhaseName(recordedId), ''));
1731
+ for (const prefix of prefixes) {
1732
+ const body = customDirectoryBodies(dir, query.projectCode).find(body => body.startsWith(prefix));
1733
+ if (body)
1734
+ return { id: recordedId, slug: body.slice(prefix.length) };
1735
+ }
1736
+ return null;
1737
+ }
1738
+ for (const body of customDirectoryBodies(dir, query.projectCode)) {
1739
+ const owner = query.prefixes.find(candidate => body.startsWith(candidate.prefix));
1740
+ if (owner)
1741
+ return { id: owner.id, slug: body.slice(owner.prefix.length) };
1742
+ }
1743
+ return null;
1744
+ }
1745
+ /** All plausible owners of a markerless directory, for destructive preflight. */
1746
+ function customDirectoryCandidates(dir, context) {
1747
+ if (context.phaseNaming !== 'custom')
1748
+ return [];
1749
+ const recorded = context.directoryIds?.[dir];
1750
+ if (recorded !== undefined) {
1751
+ if (!recorded || UNSAFE_PHASE_DIRECTORY_PART_RE.test(recorded))
1752
+ return [];
1753
+ const owner = customDirectoryOwner(dir, {
1754
+ id: recorded, listed: true, projectCode: context.projectCode,
1755
+ directoryIds: context.directoryIds, prefixes: [],
1756
+ });
1757
+ return owner ? [owner.id] : [];
1758
+ }
1759
+ const index = customIdentityIndex(context);
1760
+ const candidates = [...new Set(customDirectoryBodies(dir, context.projectCode).flatMap(body => index.prefixes.filter(candidate => body.startsWith(candidate.prefix)).map(candidate => candidate.id)))];
1761
+ // A lone legacy numeric heading is not proof of a custom identity. Preserve
1762
+ // all candidates when ownership is ambiguous, and explicit recorded hints.
1763
+ if (candidates.length === 1 && LEGACY_PHASE_ID_RE.test(candidates[0])
1764
+ && !candidates[0].includes('-') && !index.explicitIds.has(candidates[0]))
1765
+ return [];
1766
+ return candidates;
1767
+ }
1768
+ function customPhaseDirectoryParts(dir, query) {
1769
+ const owner = customDirectoryOwner(dir, query);
1770
+ if (query.recordedId !== undefined)
1771
+ return owner?.id === query.recordedId ? owner : null;
1772
+ return owner?.id.toLowerCase() === query.id.toLowerCase() ? owner : null;
1773
+ }
1295
1774
  /**
1296
1775
  * #2528: the CANONICAL phase-directory match selection — the one rule every
1297
1776
  * directory-resolution path (the shared locator plus the `find-phase` and
@@ -1299,7 +1778,11 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1299
1778
  * here because the surrounding scan/ambiguity/shaping code exists per site and
1300
1779
  * had already diverged; the selection itself must not.
1301
1780
  *
1302
- * Two passes:
1781
+ * Readers supply their naming context; legacy direct callers default to the
1782
+ * context-free numeric policy. Configured custom IDs
1783
+ * resolve through the inverse of phaseDirectoryName before normalization can
1784
+ * collapse an ID such as A-2 into a numeric query. Other queries retain the
1785
+ * legacy two passes:
1303
1786
  * 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior.
1304
1787
  * 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and
1305
1788
  * the query is a bare integer, re-filter by each directory's own LEADING
@@ -1343,31 +1826,95 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
1343
1826
  *
1344
1827
  * TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`,
1345
1828
  * the schema-drift gate (`resolvePhaseDirByToken`), `detectVerifyFailed`. Each read a directory to
1346
- * DECORATE a row they are already emitting; each used `.find()` before this
1347
- * PR, so first-match is their prior behavior preserved verbatim, and each is
1348
- * order-stable because the directory list is sorted and this function filters
1349
- * without reordering.
1829
+ * DECORATE a row they are already emitting. In bracket mode those current-
1830
+ * checkout callers pass the active project+milestone context: an exact
1831
+ * qualified match wins, and bracket directories from other milestones are
1832
+ * excluded before any first-match policy can run. Legacy selection order is
1833
+ * unchanged.
1350
1834
  *
1351
- * The honest caveat on that second tier: the bare-number fallback makes
1352
- * multi-match newly REACHABLE for inputs that previously found nothing, so those
1353
- * five can now silently pick one of several candidates where they used to report
1354
- * not-found. That is a widening of an existing first-match rule, not a new rule
1355
- * — but it is a widening, and promoting any of them to refusal is a UX decision
1356
- * about their own output, not a change to selection, so it does not belong here.
1835
+ * Without an active bracket context the historical first-match/ambiguity policy
1836
+ * remains the caller's responsibility. With a context, only migration-window
1837
+ * legacy names can participate in fallback when the exact bracket identity is
1838
+ * absent; a prior milestone's qualified directory can never mask the active one.
1357
1839
  *
1358
1840
  * `usedBareFallback` tells callers to derive the displayed phase number from
1359
1841
  * the directory's leading digit run instead of `extractPhaseToken` (whose
1360
1842
  * token for these dirs is the mis-absorbed multi-segment form).
1361
1843
  */
1362
- function matchPhaseDirs(dirs, normalized, convention) {
1363
- const primary = dirs.filter(d => phaseTokenMatches(d, normalized, convention));
1844
+ /**
1845
+ * The query a directory lookup hands to `matchPhaseDirs`. Custom projects pass
1846
+ * the id as written (their ids have no padding form); every other convention
1847
+ * keeps the normalized token callers always supplied.
1848
+ */
1849
+ function phaseLookupQuery(query, context) {
1850
+ return context?.phaseNaming === 'custom' ? query : normalizePhaseName(query);
1851
+ }
1852
+ function matchPhaseDirs(dirs, query, convention, bracketContext, context = phaseDirectoryContext({})) {
1853
+ const custom = convention === 'bracket' ? null : customPhaseQuery(query, context);
1854
+ if (custom?.recordedId !== undefined) {
1855
+ return { matches: dirs.filter(dir => customPhaseDirectoryParts(dir, custom) !== null), usedBareFallback: false };
1856
+ }
1857
+ // A complete basename names that physical directory when no record owns the
1858
+ // query. Custom projects only: a numeric project with `01` and `01-foo` must
1859
+ // still reach the #2237 ambiguity refusal for the bare token `01`.
1860
+ if (context.phaseNaming === 'custom' && convention !== 'bracket') {
1861
+ const directoryMatches = dirs.filter(dir => dir === query);
1862
+ if (directoryMatches.length > 0)
1863
+ return { matches: directoryMatches, usedBareFallback: false };
1864
+ }
1865
+ // Bracket queries arrive canonical from the lookup boundary; every other
1866
+ // convention normalizes here (custom ids keep their literal spelling below).
1867
+ const normalized = convention === 'bracket' ? query : normalizePhaseName(query);
1868
+ // Recorded directories never enter legacy token or bare-number fallback for
1869
+ // another query. Keep those compatibility passes for unmarked directories.
1870
+ if (context.phaseNaming === 'custom' && convention !== 'bracket') {
1871
+ dirs = dirs.filter(dir => context.directoryIds?.[dir] === undefined
1872
+ || (custom !== null && customPhaseDirectoryParts(dir, custom) !== null));
1873
+ }
1874
+ if (custom) {
1875
+ // A full directory name is an existing lookup form, distinct from an ID prefix.
1876
+ const exact = dirs.filter(d => d === custom.id);
1877
+ if (exact.length > 0)
1878
+ return { matches: exact, usedBareFallback: false };
1879
+ const matches = dirs.filter(d => customPhaseDirectoryParts(d, custom) !== null);
1880
+ if (matches.length > 0 || isCustomPhaseId(query))
1881
+ return { matches, usedBareFallback: false };
1882
+ }
1883
+ // Custom writers preserve an explicit ID's spelling, including an unpadded
1884
+ // letter suffix (7B). Prefer that literal token before the legacy padded form.
1885
+ if (context?.phaseNaming === 'custom' && convention !== 'bracket' && query !== normalized) {
1886
+ const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
1887
+ if (literal.length > 0)
1888
+ return { matches: literal, usedBareFallback: false };
1889
+ }
1890
+ let candidates = dirs;
1891
+ // #4304: a bare phase number from STATE/ROADMAP names the active bracket
1892
+ // identity, not every physical directory left behind by prior milestones.
1893
+ // Prefer the exact qualified directory. If it is absent, retain only
1894
+ // migration-window legacy names; a differently-qualified bracket directory
1895
+ // must never win merely because it sorts first.
1896
+ if (convention === 'bracket' && bracketContext && !bracketQualifiedKey(normalized, convention)) {
1897
+ const qualified = `${bracketContext.project}.${bracketContext.milestone}-${normalized}`;
1898
+ const exact = dirs.filter(d => phaseTokenMatches(d, qualified, convention));
1899
+ if (exact.length > 0)
1900
+ return { matches: exact, usedBareFallback: false };
1901
+ candidates = dirs.filter(d => bracketQualifiedKey(d, convention) === null);
1902
+ }
1903
+ const primary = candidates.filter(d => phaseTokenMatches(d, normalized, convention));
1364
1904
  if (primary.length > 0)
1365
1905
  return { matches: primary, usedBareFallback: false };
1906
+ // Normalization pads a literal continuation ID such as 0-00 to 00-00.
1907
+ // Preserve its exact-token lookup before falling back to a bare integer.
1908
+ if (query !== normalized && query.includes('-')) {
1909
+ const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
1910
+ if (literal.length > 0)
1911
+ return { matches: literal, usedBareFallback: false };
1912
+ }
1366
1913
  const bare = String(normalized);
1367
1914
  if (!BARE_INTEGER_RE.test(bare))
1368
1915
  return { matches: primary, usedBareFallback: false };
1369
1916
  const want = unpad(bare);
1370
- const fallback = dirs.filter(d => {
1917
+ const fallback = candidates.filter(d => {
1371
1918
  const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE);
1372
1919
  return m !== null && unpad(m[1]) === want;
1373
1920
  });
@@ -1379,9 +1926,9 @@ function matchPhaseDirs(dirs, normalized, convention) {
1379
1926
  * directory's leading digit run (the whole point of the fallback is that the
1380
1927
  * extracted token is wrong for these dirs).
1381
1928
  */
1382
- function phaseNumberForMatch(dirName, usedBareFallback) {
1929
+ function phaseNumberForMatch(dirName, usedBareFallback, convention) {
1383
1930
  if (!usedBareFallback)
1384
- return extractPhaseToken(dirName);
1931
+ return extractPhaseToken(dirName, convention);
1385
1932
  const stripped = stripProjectCodePrefix(dirName);
1386
1933
  const prefix = dirName.slice(0, dirName.length - stripped.length);
1387
1934
  const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE);
@@ -1410,7 +1957,9 @@ function phaseNumberForMatch(dirName, usedBareFallback) {
1410
1957
  * of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
1411
1958
  * Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
1412
1959
  */
1413
- function phaseKeyFromToken(token) {
1960
+ function phaseKeyFromToken(token, context) {
1961
+ if (context && customPhaseQuery(String(token), context))
1962
+ return String(token).toUpperCase();
1414
1963
  const stripped = String(token)
1415
1964
  .split('-')
1416
1965
  .map(segment => segment.replace(/^0+(?=\d)/, ''))
@@ -1430,8 +1979,16 @@ function phaseKeyFromToken(token) {
1430
1979
  * #2562's own defect class reached from the other side: both sides of a comparison
1431
1980
  * must be derived not merely by the same function but under the same convention.
1432
1981
  */
1433
- function phaseKeyFromDir(dirName, convention) {
1434
- return phaseKeyFromToken(extractPhaseToken(dirName, convention));
1982
+ function phaseKeyFromDir(dirName, convention, context) {
1983
+ if (context && convention !== 'bracket') {
1984
+ const custom = customPhaseQuery(dirName, context);
1985
+ if (custom) {
1986
+ const owner = customDirectoryOwner(dirName, custom);
1987
+ if (owner)
1988
+ return phaseKeyFromToken(owner.id, context);
1989
+ }
1990
+ }
1991
+ return phaseKeyFromToken(extractPhaseToken(dirName, convention), context);
1435
1992
  }
1436
1993
  /**
1437
1994
  * Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
@@ -1439,12 +1996,18 @@ function phaseKeyFromDir(dirName, convention) {
1439
1996
  * `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
1440
1997
  * mistaken for a non-phase. Returns null when the value does not BEGIN with a
1441
1998
  * phase token (`parsePhaseFromProse` anchoring, #2111).
1999
+ *
2000
+ * #5675: `convention` is the project's `phase_id_convention`. Under `milestone-prefixed` the id is
2001
+ * the whole `M-NN` (`3-01`), so the key equals `phaseKeyFromDir` for the same phase; every other
2002
+ * convention (and an absent one) keeps the leading-integer read. It is the trailing parameter so the
2003
+ * existing `(value, context)` callers stay byte-identical.
1442
2004
  */
1443
- function phaseKeyFromProse(value) {
2005
+ function phaseKeyFromProse(value, context, convention) {
1444
2006
  if (value == null)
1445
2007
  return null;
1446
- const { phase } = parsePhaseFromProse(String(value).replace(/[*_`~]/g, ''));
1447
- return phase === null ? null : phaseKeyFromToken(phase);
2008
+ const clean = String(value).replace(/[*_`~]/g, '');
2009
+ const { phase } = parsePhaseFromProse(clean, convention, context);
2010
+ return phase === null ? null : phaseKeyFromToken(phase, context);
1448
2011
  }
1449
2012
  /**
1450
2013
  * The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
@@ -1483,13 +2046,20 @@ function isPlannedPhaseStatusPhrase(value) {
1483
2046
  // (`3-01`), so the number capture takes one optional `-NN` segment; every other convention (and an absent
1484
2047
  // one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
1485
2048
  const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
1486
- const PHASE_PROSE_MILESTONE_PREFIXED_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+(?:-\d+)?[A-Z]?(?:\.\d+)*)\b/i;
1487
- function parsePhaseFromProse(value, convention) {
2049
+ const PHASE_PROSE_MILESTONE_PREFIXED_RE = new RegExp(`^\\s*(?:Phase\\s+)?(?:[A-Z][A-Z0-9_]*-)?(${PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED})\\b`, 'i');
2050
+ function parsePhaseFromProse(value, convention, context) {
1488
2051
  if (!value)
1489
2052
  return { phase: null, name: null };
1490
2053
  // Coerce defensively so a non-string caller cannot throw on this canonical
1491
2054
  // surface (mirrors the sibling #2121 functions' String(...) handling).
1492
2055
  const str = String(value);
2056
+ const candidate = context?.phaseNaming === 'custom'
2057
+ ? str.trim().replace(/^Phase[ \t]+/i, '').split(/[ \t:]/, 1)[0].replace(/\.$/, '')
2058
+ : null;
2059
+ const query = candidate && context && customPhaseQuery(candidate, context);
2060
+ // Only an identity a writer recorded or the roadmap lists is a phase id; an
2061
+ // ordinary word in the field (`complete`, `None`, `TBD`) is not one.
2062
+ const custom = query && (query.listed || query.recordedId !== undefined);
1493
2063
  const phaseMatch = str.match(convention === 'milestone-prefixed' ? PHASE_PROSE_MILESTONE_PREFIXED_RE : PHASE_PROSE_RE);
1494
2064
  // The name-extraction quantifiers are length-bounded so a crafted long
1495
2065
  // unterminated run (many `(` or `—`) in an untrusted STATE.md field value
@@ -1533,7 +2103,7 @@ function parsePhaseFromProse(value, convention) {
1533
2103
  ? rawName.trim()
1534
2104
  : null;
1535
2105
  return {
1536
- phase: phaseMatch ? phaseMatch[1] : null,
2106
+ phase: custom ? candidate : phaseMatch ? phaseMatch[1] : null,
1537
2107
  name,
1538
2108
  };
1539
2109
  }
@@ -1647,11 +2217,15 @@ function roadmapPhaseLookupSources(phaseNum) {
1647
2217
  sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
1648
2218
  return [...new Set(sources)];
1649
2219
  }
1650
- module.exports = {
2220
+ const phaseIdExports = {
1651
2221
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
1652
2222
  OPTIONAL_PHASE_TAG_SOURCE,
1653
2223
  PHASE_NUMBER_TOKEN_SOURCE,
2224
+ PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED,
2225
+ phaseNumberTokenSource,
1654
2226
  PHASE_DEP_REF_SOURCE,
2227
+ tokenizePhaseDependencyReferences,
2228
+ extractPhaseDependencyTokens,
1655
2229
  CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
1656
2230
  CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
1657
2231
  PHASE_CONTINUATION_SEGMENT_SOURCE,
@@ -1668,7 +2242,10 @@ module.exports = {
1668
2242
  BASE_PHASE_LABEL_PREFIX_SRC,
1669
2243
  PHASE_HEADING_BASELINE,
1670
2244
  phaseHeadingPrefixSrcFor,
2245
+ parsePhaseChecklistLine,
2246
+ bracketQualifiedIntroSrcFor,
1671
2247
  buildPhaseHeadingScanRegex,
2248
+ buildPhaseDeclarationScans,
1672
2249
  foldBracketId,
1673
2250
  bracketQualifiedKey,
1674
2251
  stripProjectCodePrefix,
@@ -1692,6 +2269,19 @@ module.exports = {
1692
2269
  scopeToPhase,
1693
2270
  phaseTokenMatches,
1694
2271
  matchPhaseDirs,
2272
+ phaseLookupQuery,
2273
+ phaseDirectoryContext,
2274
+ phaseScanTokenSource,
2275
+ phaseDirectoryName,
2276
+ validateCustomPhaseId,
2277
+ validateCustomPhaseIds,
2278
+ PhaseDirectoryNameError,
2279
+ customPhaseQuery,
2280
+ customPhaseDirectoryParts,
2281
+ customDirectoryOwner,
2282
+ customDirectoryCandidates,
2283
+ CUSTOM_PHASE_ID_FILE,
2284
+ isCustomPhaseId,
1695
2285
  phaseNumberForMatch,
1696
2286
  phaseKeyFromToken,
1697
2287
  phaseKeyFromDir,
@@ -1704,3 +2294,4 @@ module.exports = {
1704
2294
  roadmapPhaseLookupSources,
1705
2295
  renderPhaseBranchName,
1706
2296
  };
2297
+ module.exports = phaseIdExports;