@opengsd/gsd-core 1.2.0 → 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.
- package/README.ja-JP.md +6 -5
- package/README.ko-KR.md +6 -5
- package/README.md +10 -45
- package/README.pt-BR.md +5 -4
- package/README.zh-CN.md +6 -5
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-doc-writer.md +7 -6
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-planner.md +1 -1
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +1 -1
- package/assets/gsd-logo-2000-transparent.png +0 -0
- package/assets/gsd-logo-2000-transparent.svg +17 -0
- package/assets/gsd-logo-2000.png +0 -0
- package/assets/gsd-logo-2000.svg +21 -0
- package/assets/terminal.svg +68 -0
- package/bin/install.js +86 -44
- package/commands/gsd/review.md +2 -1
- package/get-shit-done/bin/gsd-tools.cjs +37 -1
- package/get-shit-done/bin/lib/command-aliases.cjs +16 -0
- package/get-shit-done/bin/lib/commands.cjs +109 -5
- package/get-shit-done/bin/lib/config-types.cjs +19 -0
- package/get-shit-done/bin/lib/core.cjs +455 -63
- package/get-shit-done/bin/lib/gap-checker.cjs +56 -7
- package/get-shit-done/bin/lib/installer-migration-report.cjs +1 -0
- package/get-shit-done/bin/lib/milestone.cjs +59 -1
- package/get-shit-done/bin/lib/model-catalog.cjs +17 -0
- package/get-shit-done/bin/lib/phase-lifecycle.cjs +0 -1
- package/get-shit-done/bin/lib/review-reviewer-selection.cjs +1 -0
- package/get-shit-done/bin/lib/roadmap-command-router.cjs +134 -0
- package/get-shit-done/bin/lib/roadmap-upgrade.cjs +569 -0
- package/get-shit-done/bin/lib/roadmap.cjs +6 -5
- package/get-shit-done/bin/lib/semver-compare.cjs +34 -27
- package/get-shit-done/bin/lib/shell-command-projection.cjs +35 -0
- package/get-shit-done/bin/lib/state-document.cjs +0 -4
- package/get-shit-done/bin/lib/state.cjs +41 -6
- package/get-shit-done/bin/lib/ui-safety-gate.cjs +111 -0
- package/get-shit-done/bin/lib/validate.cjs +43 -17
- package/get-shit-done/bin/lib/verify.cjs +107 -4
- package/get-shit-done/bin/shared/config-defaults.manifest.json +1 -0
- package/get-shit-done/bin/shared/config-schema.manifest.json +12 -1
- package/get-shit-done/bin/shared/model-catalog.json +27 -0
- package/get-shit-done/references/checkpoints.md +1 -1
- package/get-shit-done/references/planner-human-verify-mode.md +1 -1
- package/get-shit-done/references/ui-brand.md +4 -2
- package/get-shit-done/workflows/audit-fix.md +1 -1
- package/get-shit-done/workflows/audit-milestone.md +2 -0
- package/get-shit-done/workflows/autonomous.md +7 -5
- package/get-shit-done/workflows/cleanup.md +43 -3
- package/get-shit-done/workflows/code-review-fix.md +3 -3
- package/get-shit-done/workflows/code-review.md +2 -0
- package/get-shit-done/workflows/debug.md +6 -1
- package/get-shit-done/workflows/diagnose-issues.md +2 -0
- package/get-shit-done/workflows/discuss-phase/modes/advisor.md +1 -1
- package/get-shit-done/workflows/discuss-phase-assumptions.md +2 -2
- package/get-shit-done/workflows/docs-update.md +18 -4
- package/get-shit-done/workflows/eval-review.md +1 -1
- package/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/get-shit-done/workflows/execute-phase.md +21 -11
- package/get-shit-done/workflows/execute-plan.md +1 -1
- package/get-shit-done/workflows/explore.md +2 -0
- package/get-shit-done/workflows/help/modes/default.md +3 -3
- package/get-shit-done/workflows/help/modes/full.md +5 -5
- package/get-shit-done/workflows/import.md +2 -0
- package/get-shit-done/workflows/ingest-docs.md +2 -2
- package/get-shit-done/workflows/manager.md +2 -2
- package/get-shit-done/workflows/map-codebase.md +2 -0
- package/get-shit-done/workflows/new-milestone.md +2 -2
- package/get-shit-done/workflows/new-project.md +2 -2
- package/get-shit-done/workflows/plan-phase.md +16 -15
- package/get-shit-done/workflows/plan-review-convergence.md +3 -3
- package/get-shit-done/workflows/quick.md +5 -3
- package/get-shit-done/workflows/review.md +109 -1
- package/get-shit-done/workflows/scan.md +2 -0
- package/get-shit-done/workflows/secure-phase.md +2 -0
- package/get-shit-done/workflows/settings-advanced.md +195 -5
- package/get-shit-done/workflows/settings.md +15 -1
- package/get-shit-done/workflows/ui-phase.md +2 -2
- package/get-shit-done/workflows/ui-review.md +1 -1
- package/get-shit-done/workflows/validate-phase.md +2 -0
- package/get-shit-done/workflows/verify-work.md +2 -2
- package/hooks/dist/gsd-worktree-path-guard.js +169 -0
- package/hooks/gsd-worktree-path-guard.js +169 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +9 -6
- package/scripts/build-hooks.js +1 -0
- package/scripts/ci-test-scope.cjs +10 -0
- package/scripts/run-tests.cjs +25 -1
- package/scripts/lint-shared-module-handsync.cjs +0 -388
- 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'
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
}
|