@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -22,11 +22,12 @@ 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 } = phaseIdModule;
|
|
25
|
+
const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdModule;
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
27
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
28
28
|
const { planningDir } = planningWorkspace;
|
|
29
29
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
30
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
30
31
|
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
31
32
|
/**
|
|
32
33
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
@@ -97,34 +98,25 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
97
98
|
const sectionStart = selected.index;
|
|
98
99
|
const computeSectionEnd = (headingText, headingStart) => {
|
|
99
100
|
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
fc = null;
|
|
118
|
-
fl = 0;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
else if (!fc && stopPattern.test(line)) {
|
|
122
|
-
end = headingStart + headingText.length + off;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
off += line.length + 1;
|
|
101
|
+
const afterHeading = headingStart + headingText.length;
|
|
102
|
+
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
103
|
+
// the next stop boundary without re-implementing fence detection. T4 seam migration.
|
|
104
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
105
|
+
for (const h of headings) {
|
|
106
|
+
if (h.offset <= headingStart)
|
|
107
|
+
continue;
|
|
108
|
+
if (h.offset < afterHeading)
|
|
109
|
+
continue;
|
|
110
|
+
if (h.level > level)
|
|
111
|
+
continue;
|
|
112
|
+
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
|
|
113
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
114
|
+
continue;
|
|
115
|
+
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
116
|
+
continue;
|
|
117
|
+
return h.offset;
|
|
126
118
|
}
|
|
127
|
-
return
|
|
119
|
+
return content.length;
|
|
128
120
|
};
|
|
129
121
|
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
|
130
122
|
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
|
@@ -178,8 +170,8 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
|
178
170
|
const after = content.slice(offset);
|
|
179
171
|
return before + after.replace(pattern, replacement);
|
|
180
172
|
}
|
|
181
|
-
function findRoadmapPhaseInContent(content, phaseNum) {
|
|
182
|
-
const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
|
|
173
|
+
function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
|
|
174
|
+
const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
|
|
183
175
|
const headerMatch = content.match(phasePattern);
|
|
184
176
|
if (!headerMatch)
|
|
185
177
|
return null;
|
|
@@ -199,6 +191,21 @@ function findRoadmapPhaseInContent(content, phaseNum) {
|
|
|
199
191
|
section,
|
|
200
192
|
};
|
|
201
193
|
}
|
|
194
|
+
function roadmapPhaseLookupSources(phaseNum) {
|
|
195
|
+
const sources = [];
|
|
196
|
+
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
|
197
|
+
if (exactSource)
|
|
198
|
+
sources.push(exactSource);
|
|
199
|
+
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
|
200
|
+
// Source order matters: the bare numeric source is tried before the
|
|
201
|
+
// prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
|
|
202
|
+
// preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
|
|
203
|
+
// both exist in the same ROADMAP. The prefix-tolerant form is the fallback
|
|
204
|
+
// that handles the drifted-only case.
|
|
205
|
+
sources.push(numericSource);
|
|
206
|
+
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
|
207
|
+
return [...new Set(sources)];
|
|
208
|
+
}
|
|
202
209
|
function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
203
210
|
if (!phaseNum)
|
|
204
211
|
return null;
|
|
@@ -210,10 +217,16 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
210
217
|
if (roadmapRaw === null)
|
|
211
218
|
throw new Error('missing');
|
|
212
219
|
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
const fullContent = stripShippedMilestones(roadmapRaw);
|
|
221
|
+
for (const source of roadmapPhaseLookupSources(phaseNum)) {
|
|
222
|
+
const scopedResult = findRoadmapPhaseInContent(content, phaseNum, source);
|
|
223
|
+
if (scopedResult)
|
|
224
|
+
return scopedResult;
|
|
225
|
+
const fullResult = findRoadmapPhaseInContent(fullContent, phaseNum, source);
|
|
226
|
+
if (fullResult)
|
|
227
|
+
return fullResult;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
217
230
|
}
|
|
218
231
|
catch {
|
|
219
232
|
return null;
|
|
@@ -278,47 +291,6 @@ function getMilestoneInfo(cwd) {
|
|
|
278
291
|
return { version: 'v1.0', name: 'milestone' };
|
|
279
292
|
}
|
|
280
293
|
}
|
|
281
|
-
// ─── Fence-aware text helper ──────────────────────────────────────────────────
|
|
282
|
-
/**
|
|
283
|
-
* Return a copy of `text` with every line that lies inside a fenced code block
|
|
284
|
-
* replaced by an empty string, using the same fence semantics as
|
|
285
|
-
* `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle;
|
|
286
|
-
* an unclosed fence treats remaining content as fenced).
|
|
287
|
-
*/
|
|
288
|
-
function stripFencedLines(text) {
|
|
289
|
-
let fenceChar = null;
|
|
290
|
-
let fenceLen = 0;
|
|
291
|
-
const lines = text.split('\n');
|
|
292
|
-
const result = [];
|
|
293
|
-
for (const line of lines) {
|
|
294
|
-
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
|
295
|
-
if (fm) {
|
|
296
|
-
const ch = fm[1][0];
|
|
297
|
-
const ln = fm[1].length;
|
|
298
|
-
const trailing = fm[2] || '';
|
|
299
|
-
if (!fenceChar) {
|
|
300
|
-
fenceChar = ch;
|
|
301
|
-
fenceLen = ln;
|
|
302
|
-
// The fence-open line itself is not a content line — blank it.
|
|
303
|
-
result.push('');
|
|
304
|
-
}
|
|
305
|
-
else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) {
|
|
306
|
-
fenceChar = null;
|
|
307
|
-
fenceLen = 0;
|
|
308
|
-
// The fence-close line — blank it.
|
|
309
|
-
result.push('');
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
// A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content.
|
|
313
|
-
result.push(fenceChar ? '' : line);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
result.push(fenceChar ? '' : line);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return result.join('\n');
|
|
321
|
-
}
|
|
322
294
|
/**
|
|
323
295
|
* Returns a filter function that checks whether a phase directory belongs
|
|
324
296
|
* to the current milestone based on ROADMAP.md phase headings.
|
|
@@ -374,42 +346,37 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
374
346
|
else {
|
|
375
347
|
const sectionStart = sectionMatch.index;
|
|
376
348
|
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
377
|
-
const
|
|
378
|
-
|
|
349
|
+
const afterHeading = sectionStart + sectionMatch[0].length;
|
|
350
|
+
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
351
|
+
// the next milestone-boundary heading. T4 seam migration.
|
|
352
|
+
const allHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent);
|
|
379
353
|
let sectionEnd = roadmapContent.length;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
fenceLen = len;
|
|
392
|
-
}
|
|
393
|
-
else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
|
394
|
-
fenceChar = null;
|
|
395
|
-
fenceLen = 0;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
else if (!fenceChar && nextMilestonePattern.test(line)) {
|
|
399
|
-
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
charOffset += line.length + 1;
|
|
354
|
+
for (const h of allHeadings) {
|
|
355
|
+
if (h.offset < afterHeading)
|
|
356
|
+
continue;
|
|
357
|
+
if (h.level > headingLevel)
|
|
358
|
+
continue;
|
|
359
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
360
|
+
continue;
|
|
361
|
+
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
362
|
+
continue;
|
|
363
|
+
sectionEnd = h.offset;
|
|
364
|
+
break;
|
|
403
365
|
}
|
|
404
366
|
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
|
405
367
|
roadmap = currentSection;
|
|
406
368
|
}
|
|
407
369
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
370
|
+
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
|
371
|
+
// T4 seam migration: phase headings inside fences are excluded automatically.
|
|
372
|
+
const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/i;
|
|
373
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
|
|
374
|
+
if (h.level < 2 || h.level > 4)
|
|
375
|
+
continue;
|
|
376
|
+
const pm = phaseHeadingPattern.exec(h.text);
|
|
377
|
+
// Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
|
|
378
|
+
if (pm && !/^999\b/.test(pm[1]))
|
|
379
|
+
milestonePhaseNums.add(pm[1]);
|
|
413
380
|
}
|
|
414
381
|
}
|
|
415
382
|
catch { /* intentionally empty */ }
|
|
@@ -434,7 +401,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
434
401
|
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
|
435
402
|
if (customMatch && normalized.has(customMatch[1].toLowerCase()))
|
|
436
403
|
return true;
|
|
437
|
-
const stripped = dirName
|
|
404
|
+
const stripped = stripProjectCodePrefix(dirName);
|
|
438
405
|
if (stripped !== dirName) {
|
|
439
406
|
const sm = stripped.match(numericRe);
|
|
440
407
|
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase()))
|
|
@@ -15,7 +15,10 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
15
15
|
const node_child_process_1 = require("node:child_process");
|
|
16
16
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
17
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const phaseIdMod = require("./phase-id.cjs");
|
|
18
20
|
const { planningDir } = planningWorkspace;
|
|
21
|
+
const { stripProjectCodePrefix } = phaseIdMod;
|
|
19
22
|
// ─── Regex helpers ────────────────────────────────────────────────────────────
|
|
20
23
|
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
|
|
21
24
|
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
|
|
@@ -99,7 +102,7 @@ function assignSubIndices(phaseEntries) {
|
|
|
99
102
|
*/
|
|
100
103
|
function extractPhaseNumFromDir(dirName) {
|
|
101
104
|
// Strip optional project_code prefix: "GSD-01-setup" → "01-setup"
|
|
102
|
-
const stripped = dirName
|
|
105
|
+
const stripped = stripProjectCodePrefix(dirName);
|
|
103
106
|
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
|
|
104
107
|
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
|
|
105
108
|
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
|
@@ -113,7 +116,7 @@ function extractPhaseNumFromDir(dirName) {
|
|
|
113
116
|
*/
|
|
114
117
|
function buildNewDirName(oldDirName, newId, projectCode) {
|
|
115
118
|
// Strip existing project_code prefix
|
|
116
|
-
const stripped = oldDirName
|
|
119
|
+
const stripped = stripProjectCodePrefix(oldDirName);
|
|
117
120
|
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
|
|
118
121
|
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
|
|
119
122
|
const slug = slugMatch ? slugMatch[1] : stripped;
|
|
@@ -383,20 +386,30 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
383
386
|
if (gitStatus.trim().length > 0) {
|
|
384
387
|
throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
|
|
385
388
|
}
|
|
386
|
-
// Capture HEAD sha for rollback
|
|
387
|
-
let headSha;
|
|
388
|
-
try {
|
|
389
|
-
headSha = (0, node_child_process_1.execSync)('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim();
|
|
390
|
-
}
|
|
391
|
-
catch (err) {
|
|
392
|
-
throw new Error(`git rev-parse HEAD failed: ${err.message}`);
|
|
393
|
-
}
|
|
394
389
|
const pDir = planningDir(cwd);
|
|
395
390
|
const phasesDir = node_path_1.default.join(pDir, 'phases');
|
|
396
391
|
const roadmapPath = node_path_1.default.join(pDir, 'ROADMAP.md');
|
|
397
392
|
const configPath = node_path_1.default.join(pDir, 'config.json');
|
|
398
393
|
const renamedDirs = [];
|
|
399
394
|
const editedFiles = [];
|
|
395
|
+
// Surgical, git-independent rollback state (#1542). A `git reset --hard` +
|
|
396
|
+
// `git clean` rollback restores NOTHING for a gitignored `.planning/`
|
|
397
|
+
// (commit_docs:false — the default) and is a whole-repo operation besides.
|
|
398
|
+
// Instead, record the exact renames performed and snapshot each file before
|
|
399
|
+
// rewriting it, then undo precisely those on failure — correct whether
|
|
400
|
+
// `.planning/` is git-tracked or ignored.
|
|
401
|
+
const performedRenames = [];
|
|
402
|
+
const fileBackups = new Map();
|
|
403
|
+
const snapshotFile = (filePath) => {
|
|
404
|
+
if (fileBackups.has(filePath))
|
|
405
|
+
return;
|
|
406
|
+
try {
|
|
407
|
+
fileBackups.set(filePath, { existed: true, content: node_fs_1.default.readFileSync(filePath, 'utf8') });
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
fileBackups.set(filePath, { existed: false, content: '' });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
400
413
|
try {
|
|
401
414
|
// 1. Rename phase directories
|
|
402
415
|
for (const phaseEntry of plan.phases) {
|
|
@@ -404,6 +417,7 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
404
417
|
const newPath = node_path_1.default.join(phasesDir, phaseEntry.newDir);
|
|
405
418
|
if (node_fs_1.default.existsSync(oldPath)) {
|
|
406
419
|
node_fs_1.default.renameSync(oldPath, newPath);
|
|
420
|
+
performedRenames.push({ oldPath, newPath });
|
|
407
421
|
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
|
|
408
422
|
}
|
|
409
423
|
}
|
|
@@ -418,6 +432,7 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
418
432
|
lines[edit.lineIndex] = edit.to;
|
|
419
433
|
}
|
|
420
434
|
}
|
|
435
|
+
snapshotFile(roadmapPath);
|
|
421
436
|
node_fs_1.default.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
|
|
422
437
|
editedFiles.push('ROADMAP.md');
|
|
423
438
|
}
|
|
@@ -443,6 +458,7 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
443
458
|
}
|
|
444
459
|
}
|
|
445
460
|
if (changed) {
|
|
461
|
+
snapshotFile(filePath);
|
|
446
462
|
node_fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
447
463
|
editedFiles.push(fileName);
|
|
448
464
|
}
|
|
@@ -454,19 +470,33 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
454
470
|
}
|
|
455
471
|
catch { /* config may not exist yet */ }
|
|
456
472
|
configData['phase_id_convention'] = 'milestone-prefixed';
|
|
473
|
+
snapshotFile(configPath);
|
|
457
474
|
node_fs_1.default.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
|
|
458
475
|
editedFiles.push('config.json');
|
|
459
476
|
}
|
|
460
477
|
catch (err) {
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
478
|
+
// Surgical rollback: reverse the renames (newest first) and restore every
|
|
479
|
+
// file we snapshotted (deleting files that did not previously exist). This
|
|
480
|
+
// actually restores `.planning/` regardless of git tracking — so the
|
|
481
|
+
// "rolled back" claim is truthful — and never touches anything else.
|
|
482
|
+
for (let i = performedRenames.length - 1; i >= 0; i--) {
|
|
483
|
+
const { oldPath, newPath } = performedRenames[i];
|
|
484
|
+
try {
|
|
485
|
+
if (node_fs_1.default.existsSync(newPath))
|
|
486
|
+
node_fs_1.default.renameSync(newPath, oldPath);
|
|
487
|
+
}
|
|
488
|
+
catch { /* best-effort */ }
|
|
465
489
|
}
|
|
466
|
-
|
|
467
|
-
|
|
490
|
+
for (const [filePath, backup] of fileBackups) {
|
|
491
|
+
try {
|
|
492
|
+
if (backup.existed)
|
|
493
|
+
node_fs_1.default.writeFileSync(filePath, backup.content, 'utf8');
|
|
494
|
+
else if (node_fs_1.default.existsSync(filePath))
|
|
495
|
+
node_fs_1.default.unlinkSync(filePath);
|
|
496
|
+
}
|
|
497
|
+
catch { /* best-effort */ }
|
|
468
498
|
}
|
|
469
|
-
throw new Error(`Migration failed
|
|
499
|
+
throw new Error(`Migration failed and rolled back: ${err.message}`);
|
|
470
500
|
}
|
|
471
501
|
return { applied: true, renamedDirs, editedFiles };
|
|
472
502
|
}
|
|
@@ -351,8 +351,11 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
351
351
|
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
|
|
352
352
|
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
|
|
353
353
|
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
|
354
|
-
// Detect phases in summary list without detail sections (malformed ROADMAP)
|
|
355
|
-
|
|
354
|
+
// Detect phases in summary list without detail sections (malformed ROADMAP).
|
|
355
|
+
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
|
|
356
|
+
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
|
|
357
|
+
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
|
|
358
|
+
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
|
|
356
359
|
const checklistPhases = new Set();
|
|
357
360
|
let checklistMatch;
|
|
358
361
|
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|