@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6

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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -22,7 +22,10 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const node_path_1 = __importDefault(require("node:path"));
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const phaseIdModule = require("./phase-id.cjs");
25
- const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdModule;
25
+ const { escapeRegex, phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
26
+ // #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
27
+ // the lookup-source ordering); imported here rather than defined locally.
28
+ roadmapPhaseLookupSources, } = phaseIdModule;
26
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
30
  const planningWorkspace = require("./planning-workspace.cjs");
28
31
  const { planningDir } = planningWorkspace;
@@ -33,7 +36,7 @@ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
33
36
  * Strip shipped milestone content wrapped in <details> blocks.
34
37
  */
35
38
  function stripShippedMilestones(content) {
36
- return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
39
+ return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
37
40
  }
38
41
  /**
39
42
  * Extract the current milestone section from ROADMAP.md by positive lookup.
@@ -80,10 +83,9 @@ function extractCurrentMilestone(content, cwd) {
80
83
  const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
81
84
  const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
82
85
  const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
83
- const preamble = content.slice(0, preambleCutoff)
84
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
85
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
86
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
86
+ const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content.slice(0, preambleCutoff), 'details')
87
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
88
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
87
89
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
88
90
  return preamble + content.slice(detailsOpenIdx, detailsEnd);
89
91
  }
@@ -150,10 +152,9 @@ function extractCurrentMilestone(content, cwd) {
150
152
  const detailsStart = detailsMatch.index ?? 0;
151
153
  detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
152
154
  }
153
- const preamble = beforeMilestones
154
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
155
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
156
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
+ const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details')
156
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
157
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
157
158
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
158
159
  return detailsSection
159
160
  ? preamble + currentSection + '\n' + detailsSection
@@ -174,7 +175,7 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
174
175
  }
175
176
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
176
177
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
177
- const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
179
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
179
180
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
180
181
  if (headingIndex === -1)
@@ -197,21 +198,6 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
197
198
  section,
198
199
  };
199
200
  }
200
- function roadmapPhaseLookupSources(phaseNum) {
201
- const sources = [];
202
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
203
- if (exactSource)
204
- sources.push(exactSource);
205
- const numericSource = phaseMarkdownRegexSource(phaseNum);
206
- // Source order matters: the bare numeric source is tried before the
207
- // prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
208
- // preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
209
- // both exist in the same ROADMAP. The prefix-tolerant form is the fallback
210
- // that handles the drifted-only case.
211
- sources.push(numericSource);
212
- sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
213
- return [...new Set(sources)];
214
- }
215
201
  function getRoadmapPhaseInternal(cwd, phaseNum) {
216
202
  if (!phaseNum)
217
203
  return null;
@@ -323,7 +309,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
323
309
  throw new Error('missing');
324
310
  let roadmap = extractCurrentMilestone(roadmapContent, cwd);
325
311
  const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
326
- const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
312
+ const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
327
313
  if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
328
314
  console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
329
315
  'The project has phase_id_convention set to "milestone-prefixed" in config.json but the ' +
@@ -378,8 +364,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
378
364
  }
379
365
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
380
366
  // T4 seam migration: phase headings inside fences are excluded automatically.
381
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
382
- const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
367
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
368
+ const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
383
369
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
384
370
  if (h.level < 2 || h.level > 4)
385
371
  continue;
@@ -401,8 +387,13 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
401
387
  return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
402
388
  }
403
389
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
390
+ // #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
391
+ // — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
392
+ // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
393
+ // the milestone as a bogus "46-6" id.
404
394
  const numericRe = roadmapUsesHyphenedIds
405
- ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
395
+ ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
396
+ // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
406
397
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
407
398
  function isDirInMilestone(dirName) {
408
399
  const m2 = dirName.match(numericRe);
@@ -19,16 +19,16 @@ const planningWorkspace = require("./planning-workspace.cjs");
19
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
20
  const phaseIdMod = require("./phase-id.cjs");
21
21
  const { planningDir } = planningWorkspace;
22
- const { stripProjectCodePrefix } = phaseIdMod;
22
+ const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
23
23
  // ─── Regex helpers ────────────────────────────────────────────────────────────
24
24
  // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
25
25
  // Captures: (hashes)(spaces)(phase-number)(rest-of-line)
26
- const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
26
+ const LEGACY_PHASE_HEADING_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, 'i');
27
27
  // Matches already-migrated phase headings: ### Phase M-NN: Name
28
- const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
28
+ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
29
29
  // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
30
30
  // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
31
- const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
31
+ const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
32
32
  // ─── Pure computation helpers ─────────────────────────────────────────────────
33
33
  /**
34
34
  * Parse the ROADMAP.md content and build a list of phase entries with their
@@ -106,7 +106,7 @@ function extractPhaseNumFromDir(dirName) {
106
106
  const stripped = stripProjectCodePrefix(dirName);
107
107
  // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
108
108
  // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
109
- const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
109
+ const m = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-|$)`, 'i'));
110
110
  return m ? m[1] : null;
111
111
  }
112
112
  /**
@@ -119,7 +119,7 @@ function buildNewDirName(oldDirName, newId, projectCode) {
119
119
  // Strip existing project_code prefix
120
120
  const stripped = stripProjectCodePrefix(oldDirName);
121
121
  // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
122
- const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
122
+ const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i'));
123
123
  const slug = slugMatch ? slugMatch[1] : stripped;
124
124
  // Build M-NN prefix (zero-pad both parts)
125
125
  const [milestoneStr, subStr] = newId.split('-');
@@ -256,7 +256,7 @@ function computeMigrationPlan(cwd, options = {}) {
256
256
  continue;
257
257
  // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
258
258
  const oldLine = lines[entry.lineIndex];
259
- const newLine = oldLine.replace(/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i, `$1${mapping.newId}$2`);
259
+ const newLine = oldLine.replace(new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), `$1${mapping.newId}$2`);
260
260
  if (newLine !== oldLine) {
261
261
  roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
262
262
  }
@@ -274,7 +274,7 @@ function computeMigrationPlan(cwd, options = {}) {
274
274
  if (roadmapEdits.some(e => e.lineIndex === i))
275
275
  continue;
276
276
  // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
277
- const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
277
+ const checklistMatch = line.match(new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i'));
278
278
  if (checklistMatch) {
279
279
  const legacyNum = checklistMatch[2];
280
280
  const cIntPart = parseInt(legacyNum, 10);
@@ -300,7 +300,7 @@ function computeMigrationPlan(cwd, options = {}) {
300
300
  newId = found.newId;
301
301
  }
302
302
  if (newId) {
303
- const newLine = line.replace(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i, `$1${newId}$2`);
303
+ const newLine = line.replace(new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'), `$1${newId}$2`);
304
304
  if (newLine !== line) {
305
305
  roadmapEdits.push({ lineIndex: i, from: line, to: newLine });
306
306
  }
@@ -17,7 +17,7 @@ const ioMod = require("./io.cjs");
17
17
  const { output, error } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const phaseIdMod = require("./phase-id.cjs");
20
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
20
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources } = phaseIdMod;
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
@@ -102,7 +102,7 @@ function countPhasePlansAndSummaries(phaseDir) {
102
102
  */
103
103
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
104
104
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
105
- const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
105
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
106
106
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
107
107
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
108
108
  const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
@@ -174,23 +174,18 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
174
174
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
175
175
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
176
176
  const fullContent = stripShippedMilestones(rawContent);
177
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
178
- if (exactSource) {
179
- const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
180
- if (exactMilestone && !exactMilestone.error)
181
- return exactMilestone.section ?? null;
182
- const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
183
- if (exactFull && !exactFull.error)
184
- return exactFull.section ?? null;
177
+ // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
178
+ // prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
179
+ // bare-number query resolves a drifted project-code-prefixed heading.
180
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
181
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
182
+ if (milestoneResult && !milestoneResult.error)
183
+ return milestoneResult.section ?? null;
184
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
185
+ if (fullResult && !fullResult.error)
186
+ return fullResult.section ?? null;
185
187
  }
186
- const escapedPhase = phaseMarkdownRegexSource(phaseNum);
187
- const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
188
- const result = (milestoneResult && !milestoneResult.error)
189
- ? milestoneResult
190
- : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
191
- if (!result || result.error)
192
- return null;
193
- return result.section ?? null;
188
+ return null;
194
189
  }
195
190
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
196
191
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
@@ -206,46 +201,35 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
206
201
  try {
207
202
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
208
203
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
209
- // #3599 two-pass: when the caller passes a project-code-prefixed ID like
210
- // `PROJ-42`, try the exact-prefixed heading first (`### Phase PROJ-42:`).
211
- // If no match, fall back to the #3537 padding-tolerant numeric form so
212
- // a `CK-01` query still resolves to `### Phase 1:`. Doing this at the
213
- // call site (instead of inside phaseMarkdownRegexSource) avoids the
214
- // alternation-order ambiguity where a bare `### Phase 42:` heading in
215
- // the same document would intercept the match for a `PROJ-42` query.
216
204
  const fullContent = stripShippedMilestones(rawContent);
217
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
218
- if (exactSource) {
219
- const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
220
- if (exactMilestone && !exactMilestone.error) {
221
- output(exactMilestone, raw, exactMilestone.section);
205
+ // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
206
+ // prefix-tolerant) so all three roadmap resolvers share one contract and a
207
+ // bare-number query resolves a drifted `### Phase AB-29:` heading. This
208
+ // preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior
209
+ // (both now encoded in roadmapPhaseLookupSources' ordering). A clean match
210
+ // (milestone or full, any source) wins immediately; a malformed_roadmap
211
+ // (checklist-only) candidate is surfaced only if no source finds a real
212
+ // heading — so a milestone checklist never blocks a full-roadmap header.
213
+ let malformed = null;
214
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
215
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
216
+ if (milestoneResult && !milestoneResult.error) {
217
+ output(milestoneResult, raw, milestoneResult.section);
222
218
  return;
223
219
  }
224
- const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
225
- if (exactFull && !exactFull.error) {
226
- output(exactFull, raw, exactFull.section);
220
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
221
+ if (fullResult && !fullResult.error) {
222
+ output(fullResult, raw, fullResult.section);
227
223
  return;
228
224
  }
225
+ if (!malformed)
226
+ malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
229
227
  }
230
- // #3537: padding-tolerant fragment so callers passing `02.7` still match
231
- // un-padded ROADMAP prose (`### Phase 2.7:`).
232
- const escapedPhase = phaseMarkdownRegexSource(phaseNum);
233
- // Search the current milestone slice first, then fall back to full roadmap.
234
- // A malformed_roadmap result (checklist-only) from the milestone should not
235
- // block finding a full header match in the wider roadmap content.
236
- const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
237
- const result = (milestoneResult && !milestoneResult.error)
238
- ? milestoneResult
239
- : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
240
- if (!result) {
241
- output({ found: false, phase_number: phaseNum }, raw, '');
242
- return;
243
- }
244
- if (result.error) {
245
- output(result, raw, '');
228
+ if (malformed) {
229
+ output(malformed, raw, '');
246
230
  return;
247
231
  }
248
- output(result, raw, result.section);
232
+ output({ found: false, phase_number: phaseNum }, raw, '');
249
233
  }
250
234
  catch (e) {
251
235
  error('Failed to read ROADMAP.md: ' + e.message);
@@ -262,8 +246,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
262
246
  const content = extractCurrentMilestone(rawContent, cwd);
263
247
  const phasesDir = planningPaths(cwd).phases;
264
248
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
265
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
266
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
249
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
250
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
251
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
267
252
  const phases = [];
268
253
  let match;
269
254
  // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
@@ -296,7 +281,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
296
281
  const restOfContent = content.slice(sectionStart);
297
282
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
298
283
  // recognised as section boundaries.
299
- const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
284
+ const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i);
300
285
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
301
286
  const section = content.slice(sectionStart, sectionEnd);
302
287
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -383,6 +368,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
383
368
  // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
384
369
  // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
385
370
  // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
371
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
386
372
  const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
387
373
  const checklistPhases = new Set();
388
374
  let checklistMatch;
@@ -473,7 +459,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
473
459
  // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
474
460
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
475
461
  // `Plans: N plans` — plain text header
476
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
462
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
477
463
  const planCountText = isComplete
478
464
  ? `${summaryCount}/${planCount} plans complete`
479
465
  : `${summaryCount}/${planCount} plans executed`;
@@ -530,8 +516,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
530
516
  //
531
517
  // Pattern A: anchor to bare `Plans:` header (preferred).
532
518
  // Pattern B: fallback to bold summary when no bare header exists.
533
- const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
534
- const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
519
+ const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
520
+ const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
535
521
  const sortedMissing = [...missingPlans].sort();
536
522
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
537
523
  const inserter = (match) => `${match}\n${newRows}`;