@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.
Files changed (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. 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 rest = content.slice(headingStart + headingText.length);
101
- const stopPattern = new RegExp(`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
102
- let end = content.length;
103
- let fc = null;
104
- let fl = 0;
105
- let off = 0;
106
- for (const line of rest.split('\n')) {
107
- const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
108
- if (fm) {
109
- const ch = fm[1][0];
110
- const ln = fm[1].length;
111
- const trailing = fm[2] || '';
112
- if (!fc) {
113
- fc = ch;
114
- fl = ln;
115
- }
116
- else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
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 end;
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 scopedResult = findRoadmapPhaseInContent(content, phaseNum);
214
- if (scopedResult)
215
- return scopedResult;
216
- return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum);
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 restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
378
- const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
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
- let fenceChar = null;
381
- let fenceLen = 0;
382
- let charOffset = 0;
383
- for (const line of restContent.split('\n')) {
384
- const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
385
- if (fenceMatch) {
386
- const char = fenceMatch[1][0];
387
- const len = fenceMatch[1].length;
388
- const trailing = fenceMatch[2] || '';
389
- if (!fenceChar) {
390
- fenceChar = char;
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
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
409
- const roadmapUnfenced = stripFencedLines(roadmap);
410
- let m;
411
- while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
412
- milestonePhaseNums.add(m[1]);
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.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
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.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
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.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
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
- // Rollback via git reset --hard + git clean
462
- try {
463
- (0, node_child_process_1.execSync)(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true });
464
- (0, node_child_process_1.execSync)('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true });
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
- catch {
467
- // Swallow rollback errors — surface original error
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 (rolled back to ${headSha}): ${err.message}`);
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
- const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
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) {