@opengsd/gsd-core 1.2.0-rc.1 → 1.3.0-rc.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 (92) hide show
  1. package/README.ja-JP.md +6 -5
  2. package/README.ko-KR.md +6 -5
  3. package/README.md +10 -45
  4. package/README.pt-BR.md +5 -4
  5. package/README.zh-CN.md +6 -5
  6. package/agents/gsd-ai-researcher.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +1 -1
  8. package/agents/gsd-doc-writer.md +7 -6
  9. package/agents/gsd-domain-researcher.md +1 -1
  10. package/agents/gsd-eval-planner.md +1 -1
  11. package/agents/gsd-phase-researcher.md +1 -1
  12. package/agents/gsd-ui-researcher.md +1 -1
  13. package/agents/gsd-verifier.md +1 -1
  14. package/assets/gsd-logo-2000-transparent.png +0 -0
  15. package/assets/gsd-logo-2000-transparent.svg +17 -0
  16. package/assets/gsd-logo-2000.png +0 -0
  17. package/assets/gsd-logo-2000.svg +21 -0
  18. package/assets/terminal.svg +68 -0
  19. package/bin/install.js +86 -44
  20. package/commands/gsd/review.md +2 -1
  21. package/get-shit-done/bin/gsd-tools.cjs +37 -1
  22. package/get-shit-done/bin/lib/command-aliases.cjs +16 -0
  23. package/get-shit-done/bin/lib/commands.cjs +109 -5
  24. package/get-shit-done/bin/lib/config-types.cjs +19 -0
  25. package/get-shit-done/bin/lib/core.cjs +455 -63
  26. package/get-shit-done/bin/lib/gap-checker.cjs +56 -7
  27. package/get-shit-done/bin/lib/installer-migration-report.cjs +1 -0
  28. package/get-shit-done/bin/lib/milestone.cjs +59 -1
  29. package/get-shit-done/bin/lib/model-catalog.cjs +17 -0
  30. package/get-shit-done/bin/lib/phase-lifecycle.cjs +0 -1
  31. package/get-shit-done/bin/lib/review-reviewer-selection.cjs +1 -0
  32. package/get-shit-done/bin/lib/roadmap-command-router.cjs +134 -0
  33. package/get-shit-done/bin/lib/roadmap-upgrade.cjs +569 -0
  34. package/get-shit-done/bin/lib/roadmap.cjs +6 -5
  35. package/get-shit-done/bin/lib/semver-compare.cjs +34 -27
  36. package/get-shit-done/bin/lib/shell-command-projection.cjs +35 -0
  37. package/get-shit-done/bin/lib/state-document.cjs +0 -4
  38. package/get-shit-done/bin/lib/state.cjs +41 -6
  39. package/get-shit-done/bin/lib/ui-safety-gate.cjs +111 -0
  40. package/get-shit-done/bin/lib/validate.cjs +43 -17
  41. package/get-shit-done/bin/lib/verify.cjs +107 -4
  42. package/get-shit-done/bin/shared/config-defaults.manifest.json +1 -0
  43. package/get-shit-done/bin/shared/config-schema.manifest.json +12 -1
  44. package/get-shit-done/bin/shared/model-catalog.json +27 -0
  45. package/get-shit-done/references/checkpoints.md +1 -1
  46. package/get-shit-done/references/planner-human-verify-mode.md +1 -1
  47. package/get-shit-done/references/ui-brand.md +4 -2
  48. package/get-shit-done/workflows/audit-fix.md +1 -1
  49. package/get-shit-done/workflows/audit-milestone.md +2 -0
  50. package/get-shit-done/workflows/autonomous.md +7 -5
  51. package/get-shit-done/workflows/cleanup.md +43 -3
  52. package/get-shit-done/workflows/code-review-fix.md +3 -3
  53. package/get-shit-done/workflows/code-review.md +2 -0
  54. package/get-shit-done/workflows/debug.md +6 -1
  55. package/get-shit-done/workflows/diagnose-issues.md +2 -0
  56. package/get-shit-done/workflows/discuss-phase/modes/advisor.md +1 -1
  57. package/get-shit-done/workflows/discuss-phase-assumptions.md +2 -2
  58. package/get-shit-done/workflows/docs-update.md +18 -4
  59. package/get-shit-done/workflows/eval-review.md +1 -1
  60. package/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  61. package/get-shit-done/workflows/execute-phase.md +21 -11
  62. package/get-shit-done/workflows/execute-plan.md +1 -1
  63. package/get-shit-done/workflows/explore.md +2 -0
  64. package/get-shit-done/workflows/help/modes/default.md +3 -3
  65. package/get-shit-done/workflows/help/modes/full.md +5 -5
  66. package/get-shit-done/workflows/import.md +2 -0
  67. package/get-shit-done/workflows/ingest-docs.md +2 -2
  68. package/get-shit-done/workflows/manager.md +2 -2
  69. package/get-shit-done/workflows/map-codebase.md +2 -0
  70. package/get-shit-done/workflows/new-milestone.md +2 -2
  71. package/get-shit-done/workflows/new-project.md +2 -2
  72. package/get-shit-done/workflows/plan-phase.md +16 -15
  73. package/get-shit-done/workflows/plan-review-convergence.md +3 -3
  74. package/get-shit-done/workflows/quick.md +5 -3
  75. package/get-shit-done/workflows/review.md +109 -1
  76. package/get-shit-done/workflows/scan.md +2 -0
  77. package/get-shit-done/workflows/secure-phase.md +2 -0
  78. package/get-shit-done/workflows/settings-advanced.md +195 -5
  79. package/get-shit-done/workflows/settings.md +15 -1
  80. package/get-shit-done/workflows/ui-phase.md +2 -2
  81. package/get-shit-done/workflows/ui-review.md +1 -1
  82. package/get-shit-done/workflows/validate-phase.md +2 -0
  83. package/get-shit-done/workflows/verify-work.md +2 -2
  84. package/hooks/dist/gsd-worktree-path-guard.js +169 -0
  85. package/hooks/gsd-worktree-path-guard.js +169 -0
  86. package/hooks/managed-hooks-registry.cjs +1 -0
  87. package/package.json +9 -6
  88. package/scripts/build-hooks.js +1 -0
  89. package/scripts/ci-test-scope.cjs +10 -0
  90. package/scripts/run-tests.cjs +25 -1
  91. package/scripts/lint-shared-module-handsync.cjs +0 -388
  92. package/scripts/shared-module-handsync-allowlist.json +0 -183
@@ -99,7 +99,9 @@ function formatGapTable(rows) {
99
99
  }
100
100
  const header = '| Source | Item | Status |\n|--------|------|--------|';
101
101
  const body = rows.map(r => {
102
- const tick = r.status === 'Covered' ? '\u2713 Covered' : '\u2717 Not covered';
102
+ const tick = r.status === 'Covered' ? '\u2713 Covered'
103
+ : r.status === 'Missing from REQUIREMENTS.md' ? '\u26a0 Missing from REQUIREMENTS.md'
104
+ : '\u2717 Not covered';
103
105
  return `| ${r.source} | ${r.item} | ${tick} |`;
104
106
  }).join('\n');
105
107
  return `## Post-Planning Gap Analysis\n\n${header}\n${body}\n`;
@@ -116,7 +118,30 @@ function readGate(cwd) {
116
118
  return true;
117
119
  }
118
120
 
119
- function runGapAnalysis(cwd, phaseDir) {
121
+ /**
122
+ * Normalize a raw `--phase-req-ids` argument into the scoping signal used by
123
+ * runGapAnalysis (#447). Mirrors §13's null/TBD skip semantics.
124
+ *
125
+ * undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat)
126
+ * null | '' | TBD → no requirements mapped to this phase: skip the comparison
127
+ * "REQ-01,REQ-02" → restrict the comparison to these IDs
128
+ *
129
+ * Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
130
+ * the roadmap value through verbatim.
131
+ */
132
+ function normalizePhaseReqIds(rawVal) {
133
+ if (rawVal === undefined) return undefined;
134
+ if (rawVal === null) return null;
135
+ const v = String(rawVal).replace(/["'[\]()]/g, '').trim();
136
+ if (v === '' || /^(null|tbd|none)$/i.test(v)) return null;
137
+ // Tolerate comma-, space-, or newline-separated lists (callers may pass the
138
+ // roadmap value verbatim, whose serialization is not guaranteed).
139
+ const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
140
+ return ids.length === 0 ? null : ids;
141
+ }
142
+
143
+ function runGapAnalysis(cwd, phaseDir, options = {}) {
144
+ const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
120
145
  if (!readGate(cwd)) {
121
146
  return {
122
147
  enabled: false,
@@ -131,7 +156,21 @@ function runGapAnalysis(cwd, phaseDir) {
131
156
 
132
157
  const reqPath = planningPaths(cwd).requirements;
133
158
  const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
134
- const reqItems = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
159
+ let reqItems = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
160
+
161
+ // Scope the requirements comparison to the phase's mapped REQ-IDs (#447).
162
+ // A phase that maps no requirements (phase_req_ids null/TBD) must not report
163
+ // every unrelated project REQ-ID as a gap — mirror §13's skip behavior.
164
+ // CONTEXT.md decisions (below) are always in scope regardless.
165
+ let ghostReqIds = [];
166
+ if (phaseReqIds === null) {
167
+ reqItems = [];
168
+ } else if (Array.isArray(phaseReqIds)) {
169
+ const wanted = new Set(phaseReqIds);
170
+ const foundIds = new Set(reqItems.map(r => r.id));
171
+ reqItems = reqItems.filter(r => wanted.has(r.id));
172
+ ghostReqIds = phaseReqIds.filter(id => !foundIds.has(id));
173
+ }
135
174
 
136
175
  // Read the phase directory once; reuse the listing for both context detection
137
176
  // and plan-file enumeration (avoids redundant readdirSync calls).
@@ -168,9 +207,12 @@ function runGapAnalysis(cwd, phaseDir) {
168
207
  };
169
208
  }
170
209
 
171
- const rows = sortRows(detectCoverage(items, planText));
172
- const uncovered = rows.filter(r => r.status === 'Not covered').length;
173
- const covered = rows.length - uncovered;
210
+ const rows = sortRows([
211
+ ...detectCoverage(items, planText),
212
+ ...ghostReqIds.map(id => ({ source: 'REQUIREMENTS.md', item: id, status: 'Missing from REQUIREMENTS.md' })),
213
+ ]);
214
+ const covered = rows.filter(r => r.status === 'Covered').length;
215
+ const uncovered = rows.length - covered;
174
216
 
175
217
  const summary = uncovered === 0
176
218
  ? `\u2713 All ${rows.length} items covered by plans`
@@ -191,7 +233,13 @@ function cmdGapAnalysis(cwd, args, raw) {
191
233
  error('Usage: gap-analysis --phase-dir <path-to-phase-directory>');
192
234
  }
193
235
  const phaseDir = args[idx + 1];
194
- const result = runGapAnalysis(cwd, phaseDir);
236
+
237
+ // Optional --phase-req-ids scopes the requirements comparison (#447).
238
+ // Absent → compare the whole REQUIREMENTS.md (back-compat).
239
+ const reqIdx = args.indexOf('--phase-req-ids');
240
+ const phaseReqIds = reqIdx === -1 ? undefined : (args[reqIdx + 1] ?? '');
241
+
242
+ const result = runGapAnalysis(cwd, phaseDir, { phaseReqIds });
195
243
  output(result, raw, result.table || result.summary);
196
244
  }
197
245
 
@@ -200,6 +248,7 @@ module.exports = {
200
248
  detectCoverage,
201
249
  formatGapTable,
202
250
  sortRows,
251
+ normalizePhaseReqIds,
203
252
  runGapAnalysis,
204
253
  cmdGapAnalysis,
205
254
  };
@@ -122,6 +122,7 @@ const BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
122
122
  'hooks/gsd-update-banner.js',
123
123
  'hooks/gsd-validate-commit.sh',
124
124
  'hooks/gsd-workflow-guard.js',
125
+ 'hooks/gsd-worktree-path-guard.js',
125
126
  ]));
126
127
 
127
128
  // Classify a blocked prompt-user action into one of the safe-default
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, output, error } = require('./core.cjs');
7
+ const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error } = require('./core.cjs');
8
8
  const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
9
9
  const { planningPaths } = require('./planning-workspace.cjs');
10
10
  const { extractFrontmatter } = require('./frontmatter.cjs');
@@ -114,6 +114,64 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
114
114
  error(`no phases found for milestone ${version} in ROADMAP.md`);
115
115
  }
116
116
 
117
+ // Guard: prevent marking complete when ROADMAP still lists phases that have
118
+ // no directory on disk (disk_status: no_directory). This catches the case
119
+ // where the active milestone was erroneously marked complete before phases
120
+ // were even started. Only fires when STATE.md confirms the current milestone
121
+ // version matches what is being completed — no false positives on fresh
122
+ // projects where phases haven't been scaffolded yet.
123
+ // Pass --force to override this guard.
124
+ if (!options.force) {
125
+ try {
126
+ // Only guard when STATE.md's milestone field matches the version being completed.
127
+ let stateVersion = null;
128
+ try {
129
+ const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
130
+ if (stateRaw) {
131
+ const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
132
+ if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
133
+ }
134
+ } catch { /* skip */ }
135
+
136
+ if (stateVersion && stateVersion === version) {
137
+ const { extractCurrentMilestone } = require('./core.cjs');
138
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
139
+ const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
140
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
141
+ const noDirectoryPhases = [];
142
+ let pm;
143
+ const phaseDirEntries = (() => {
144
+ try {
145
+ return fs.readdirSync(phasesDir, { withFileTypes: true })
146
+ .filter(e => e.isDirectory()).map(e => e.name);
147
+ } catch { return []; }
148
+ })();
149
+ while ((pm = phasePattern.exec(scopedContent)) !== null) {
150
+ const phaseNum = pm[1];
151
+ const normalized = normalizePhaseName(phaseNum);
152
+ // A phase has disk_status: 'no_directory' when no phase directory
153
+ // with a matching token exists on disk. Use the same phaseTokenMatches
154
+ // helper that roadmap.analyze uses to avoid false positives on decimal
155
+ // (2.1) and letter-suffix (12A) phase IDs.
156
+ const hasDirectory = phaseDirEntries.some(d => phaseTokenMatches(d, normalized));
157
+ if (!hasDirectory) {
158
+ noDirectoryPhases.push(phaseNum);
159
+ }
160
+ }
161
+ if (noDirectoryPhases.length > 0) {
162
+ error(
163
+ `Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
164
+ `(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`
165
+ );
166
+ }
167
+ }
168
+ } catch (e) {
169
+ // If the error came from our guard, re-throw it; otherwise skip silently.
170
+ if (e.message && e.message.startsWith('Cannot mark milestone complete:')) throw e;
171
+ // Phase scan failed or STATE version mismatch — allow completion to proceed.
172
+ }
173
+ }
174
+
117
175
  // Gather stats from phases (scoped to current milestone only)
118
176
  let phaseCount = 0;
119
177
  let totalPlans = 0;
@@ -90,6 +90,21 @@ const RUNTIMES_WITH_REASONING_EFFORT = new Set(
90
90
  .map(([runtime]) => runtime)
91
91
  );
92
92
 
93
+ const PROVIDER_PRESETS = catalog.providerPresets || {};
94
+
95
+ // KNOWN_PROVIDERS excludes 'generic' — it is a sentinel (all null entries) that
96
+ // forces users to supply model IDs via model_profile_overrides. It is not a
97
+ // real catalog-backed provider (#49).
98
+ const KNOWN_PROVIDERS = new Set(
99
+ Object.entries(PROVIDER_PRESETS)
100
+ .filter(([, tiers]) =>
101
+ Object.values(tiers).some((budgets) =>
102
+ budgets && Object.values(budgets).some((entry) => entry && entry.model)
103
+ )
104
+ )
105
+ .map(([name]) => name)
106
+ );
107
+
93
108
  function nextTier(currentTier) {
94
109
  const order = ['light', 'standard', 'heavy'];
95
110
  const idx = order.indexOf(String(currentTier));
@@ -203,6 +218,8 @@ module.exports = {
203
218
  RUNTIME_PROFILE_MAP,
204
219
  KNOWN_RUNTIMES,
205
220
  RUNTIMES_WITH_REASONING_EFFORT,
221
+ PROVIDER_PRESETS,
222
+ KNOWN_PROVIDERS,
206
223
  nextTier,
207
224
  formatAgentToModelMapAsTable,
208
225
  getAgentToModelMapForProfile,
@@ -55,7 +55,6 @@ function deriveProgressFromRoadmap(roadmapContent) {
55
55
  let totalPlansSum = 0;
56
56
  const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
57
57
  let pm;
58
- // eslint-disable-next-line no-cond-assign
59
58
  while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
60
59
  totalPlansSum += parseInt(pm[2], 10);
61
60
  }
@@ -15,6 +15,7 @@ const KNOWN_REVIEWER_SLUGS = [
15
15
  'opencode',
16
16
  'qwen',
17
17
  'cursor',
18
+ 'antigravity',
18
19
  'ollama',
19
20
  'lm_studio',
20
21
  'llama_cpp',
@@ -1,7 +1,91 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.cjs');
4
6
  const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
7
+ const roadmapUpgrade = require('./roadmap-upgrade.cjs');
8
+ const { planningDir } = require('./planning-workspace.cjs');
9
+ const { loadConfig } = require('./core.cjs');
10
+
11
+ /**
12
+ * Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations.
13
+ *
14
+ * W021: a phase whose ID integer prefix does not match its enclosing milestone's
15
+ * major version number.
16
+ *
17
+ * Sentinel milestones (0 = pre-milestone, 999 = backlog) are exempt.
18
+ *
19
+ * @param {string} content - ROADMAP.md content
20
+ * @returns {Array<{code:'W021', message:string}>}
21
+ */
22
+ function checkW021(content) {
23
+ const warnings = [];
24
+
25
+ // Sentinel milestone integers exempt from W021
26
+ const SENTINELS = new Set([0, 999]);
27
+ const MIGRATION_CMD = 'gsd-tools roadmap upgrade --convention milestone-prefixed';
28
+
29
+ // Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
30
+ // OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
31
+ // Capture the major integer.
32
+ const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
33
+
34
+ // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
35
+ const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
36
+ // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
37
+ const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
38
+
39
+ let currentMilestoneMajor = null;
40
+ const lines = content.split('\n');
41
+
42
+ for (const line of lines) {
43
+ const milestoneMatch = line.match(MILESTONE_RE);
44
+ if (milestoneMatch) {
45
+ currentMilestoneMajor = parseInt(milestoneMatch[1], 10);
46
+ continue;
47
+ }
48
+
49
+ const phaseMatch = line.match(PHASE_RE);
50
+ if (phaseMatch) {
51
+ const phaseMajor = parseInt(phaseMatch[1], 10);
52
+ if (SENTINELS.has(phaseMajor)) continue; // exempt
53
+
54
+ if (currentMilestoneMajor !== null && phaseMajor !== currentMilestoneMajor) {
55
+ const phaseId = `${phaseMatch[1]}-${phaseMatch[2]}`;
56
+ warnings.push({
57
+ code: 'W021',
58
+ message:
59
+ `Phase ID prefix mismatch: phase "${phaseId}" is listed under v${currentMilestoneMajor}.x ` +
60
+ `but its prefix (${phaseMajor}) does not match. ` +
61
+ `Run \`${MIGRATION_CMD}\` to fix.`,
62
+ });
63
+ }
64
+ continue;
65
+ }
66
+
67
+ // When the convention is active, an unprefixed heading (### Phase 1:) is itself a W021
68
+ // violation — it is missing the required M-NN prefix entirely.
69
+ const unprefixedMatch = line.match(UNPREFIXED_PHASE_RE);
70
+ if (unprefixedMatch && currentMilestoneMajor !== null) {
71
+ const rawId = unprefixedMatch[1];
72
+ // Skip if it matched PHASE_RE already (it didn't reach here in that case)
73
+ // Also skip if it looks like a bare integer whose prefix matches the section
74
+ // — those pass; only non-matching or non-prefixed forms fire W021.
75
+ const numericMajor = parseInt(rawId, 10);
76
+ if (!SENTINELS.has(numericMajor)) {
77
+ warnings.push({
78
+ code: 'W021',
79
+ message:
80
+ `Phase ID "${rawId}" is not in M-NN form (milestone-prefixed convention is active). ` +
81
+ `Run \`${MIGRATION_CMD}\` to migrate.`,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ return warnings;
88
+ }
5
89
 
6
90
  /**
7
91
  * Manifest-backed roadmap subcommand router.
@@ -19,6 +103,56 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
19
103
  analyze: () => roadmap.cmdRoadmapAnalyze(cwd, raw),
20
104
  'update-plan-progress': () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw),
21
105
  'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
106
+ 'validate': () => {
107
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
108
+ let roadmapContent = '';
109
+ try {
110
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
111
+ } catch {
112
+ // ROADMAP.md missing — return empty warnings
113
+ }
114
+
115
+ // W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'.
116
+ // Authoritative source: .planning/config.json (set by the upgrade command).
117
+ // Fallback: ROADMAP.md frontmatter (for projects that set the field there directly).
118
+ let convention;
119
+ try {
120
+ const cfg = loadConfig(cwd);
121
+ convention = cfg.phase_id_convention;
122
+ } catch {
123
+ convention = undefined;
124
+ }
125
+ if (convention === undefined || convention === null) {
126
+ // Fallback: read from ROADMAP.md frontmatter
127
+ const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
128
+ if (fmMatch) {
129
+ const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
130
+ if (kvMatch) {
131
+ const val = kvMatch[1].trim();
132
+ if (val !== 'null' && val !== '') {
133
+ convention = val.replace(/^["']|["']$/g, '');
134
+ }
135
+ }
136
+ }
137
+ }
138
+ const warnings = (convention === 'milestone-prefixed')
139
+ ? checkW021(roadmapContent)
140
+ : [];
141
+
142
+ const result = { warnings };
143
+ if (raw) process.stdout.write(JSON.stringify(result));
144
+ else process.stdout.write(JSON.stringify(result, null, 2));
145
+ },
146
+ 'upgrade': () => {
147
+ const dryRun = !args.includes('--apply');
148
+ const convention = args.find((a, i) => args[i-1] === '--convention') || 'milestone-prefixed';
149
+ if (convention !== 'milestone-prefixed') {
150
+ process.stderr.write('Only --convention milestone-prefixed is supported\n');
151
+ process.exit(1);
152
+ }
153
+ const plan = roadmapUpgrade.computeMigrationPlan(cwd);
154
+ roadmapUpgrade.applyMigration(cwd, plan, { dryRun });
155
+ },
22
156
  },
23
157
  });
24
158
  }