@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.
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
@@ -0,0 +1,569 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs
5
+ * to milestone-prefixed 'Phase M-NN' form.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execSync } = require('child_process');
11
+ const { planningDir } = require('./planning-workspace.cjs');
12
+
13
+ // ─── Regex helpers ────────────────────────────────────────────────────────────
14
+
15
+ // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
16
+ // Captures: (hashes)(spaces)(phase-number)(rest-of-line)
17
+ const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
18
+
19
+ // Matches already-migrated phase headings: ### Phase M-NN: Name
20
+ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
21
+
22
+ // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
23
+ // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
24
+ const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
25
+
26
+ // Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal)
27
+ const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
28
+
29
+ // ─── Pure computation helpers ─────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Parse the ROADMAP.md content and build a list of phase entries with their
33
+ * enclosing milestone major version.
34
+ *
35
+ * Returns an array of:
36
+ * { lineIndex, headingLine, milestoneInt, legacyPhaseNum, phaseName }
37
+ */
38
+ function parseRoadmapPhases(lines) {
39
+ const results = [];
40
+ let currentMilestoneInt = null;
41
+
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i];
44
+
45
+ const milestoneMatch = line.match(MILESTONE_HEADING_RE);
46
+ if (milestoneMatch) {
47
+ currentMilestoneInt = parseInt(milestoneMatch[1], 10);
48
+ continue;
49
+ }
50
+
51
+ if (MIGRATED_PHASE_HEADING_RE.test(line)) {
52
+ // Already-migrated heading found — caller will detect this
53
+ results.push({ lineIndex: i, headingLine: line, alreadyMigrated: true });
54
+ continue;
55
+ }
56
+
57
+ const phaseMatch = line.match(LEGACY_PHASE_HEADING_RE);
58
+ if (phaseMatch) {
59
+ results.push({
60
+ lineIndex: i,
61
+ headingLine: line,
62
+ milestoneInt: currentMilestoneInt,
63
+ legacyPhaseNum: phaseMatch[2],
64
+ phaseName: phaseMatch[3].trim(),
65
+ hashes: phaseMatch[1],
66
+ alreadyMigrated: false,
67
+ });
68
+ }
69
+ }
70
+
71
+ return results;
72
+ }
73
+
74
+ /**
75
+ * Assign sub-indices within each milestone, building a per-entry mapping.
76
+ *
77
+ * Input: array from parseRoadmapPhases (non-migrated entries only).
78
+ * Returns: Map<lineIndex, { newId, milestoneInt, subIndex }>
79
+ *
80
+ * Keyed by `lineIndex` (the unique position of the heading line in ROADMAP.md)
81
+ * so that identical legacy phase numbers in different milestones (e.g., two
82
+ * `Phase 1` headings in v1.0 and v2.0) each get their own correct M-NN ID
83
+ * instead of the later milestone's mapping overwriting the earlier one.
84
+ *
85
+ * Sub-indices are 1-based and sequential within each milestone.
86
+ */
87
+ function assignSubIndices(phaseEntries) {
88
+ const milestoneCounters = new Map(); // milestoneInt → counter
89
+ const mapping = new Map(); // lineIndex → { newId, milestoneInt, subIndex }
90
+
91
+ for (const entry of phaseEntries) {
92
+ if (entry.alreadyMigrated) continue;
93
+ const m = entry.milestoneInt;
94
+ if (m === null || m === undefined) continue;
95
+
96
+ const counter = (milestoneCounters.get(m) || 0) + 1;
97
+ milestoneCounters.set(m, counter);
98
+
99
+ const subIndex = String(counter).padStart(2, '0');
100
+ const newId = `${m}-${subIndex}`;
101
+
102
+ mapping.set(entry.lineIndex, { newId, milestoneInt: m, subIndex: counter, legacyPhaseNum: entry.legacyPhaseNum });
103
+ }
104
+
105
+ return mapping;
106
+ }
107
+
108
+ /**
109
+ * Read a phase directory name and return its numeric token (stripping project_code prefix).
110
+ * e.g. "GSD-01-setup" → "01", "01-setup" → "01", "02-implement" → "02", "02.1-hotfix" → "02.1"
111
+ */
112
+ function extractPhaseNumFromDir(dirName) {
113
+ // Strip optional project_code prefix: "GSD-01-setup" → "01-setup"
114
+ const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
115
+ // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
116
+ // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
117
+ const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
118
+ return m ? m[1] : null;
119
+ }
120
+
121
+
122
+ /**
123
+ * Build the new directory name from old name and new phase ID.
124
+ * old: "01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
125
+ * old: "01-setup" newId: "1-02" projectCode: null → "01-02-setup"
126
+ * old: "GSD-01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
127
+ */
128
+ function buildNewDirName(oldDirName, newId, projectCode) {
129
+ // Strip existing project_code prefix
130
+ const stripped = oldDirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
131
+
132
+ // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
133
+ const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
134
+ const slug = slugMatch ? slugMatch[1] : stripped;
135
+
136
+ // Build M-NN prefix (zero-pad both parts)
137
+ const [milestoneStr, subStr] = newId.split('-');
138
+ const milestoneInt = parseInt(milestoneStr, 10);
139
+ const subIndex = subStr;
140
+ const paddedMilestone = String(milestoneInt).padStart(2, '0');
141
+ const newBase = slug ? `${paddedMilestone}-${subIndex}-${slug}` : `${paddedMilestone}-${subIndex}`;
142
+
143
+ return projectCode ? `${projectCode}-${newBase}` : newBase;
144
+ }
145
+
146
+ /**
147
+ * Read project_code from config.json if present.
148
+ */
149
+ function readProjectCode(configPath) {
150
+ try {
151
+ const raw = fs.readFileSync(configPath, 'utf8');
152
+ const parsed = JSON.parse(raw);
153
+ return parsed.project_code || null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // ─── computeMigrationPlan ─────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Compute a migration plan without touching the filesystem.
163
+ *
164
+ * @param {string} cwd
165
+ * @param {object} [options]
166
+ * @returns {{
167
+ * alreadyMigrated: boolean,
168
+ * phases: Array<{oldId, newId, oldDir, newDir}>,
169
+ * roadmapEdits: Array<{lineIndex, from, to}>,
170
+ * crossRefEdits: Array<{file, from, to}>,
171
+ * }}
172
+ */
173
+ function computeMigrationPlan(cwd, options = {}) {
174
+ const pDir = planningDir(cwd);
175
+ const roadmapPath = path.join(pDir, 'ROADMAP.md');
176
+ const configPath = path.join(pDir, 'config.json');
177
+ const phasesDir = path.join(pDir, 'phases');
178
+
179
+ // ── Check config for existing convention ─────────────────────────────────
180
+ let configData = {};
181
+ try {
182
+ configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
183
+ } catch { /* config may not exist */ }
184
+
185
+ if (configData.phase_id_convention === 'milestone-prefixed') {
186
+ return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
187
+ }
188
+
189
+ const projectCode = configData.project_code || null;
190
+
191
+ // ── Read ROADMAP.md ───────────────────────────────────────────────────────
192
+ let roadmapContent = '';
193
+ try {
194
+ roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
195
+ } catch {
196
+ throw new Error(`ROADMAP.md not found at ${roadmapPath}`);
197
+ }
198
+
199
+ const lines = roadmapContent.split('\n');
200
+ const parsedPhases = parseRoadmapPhases(lines);
201
+
202
+ // Check for any already-migrated headings
203
+ const hasAnyMigrated = parsedPhases.some(e => e.alreadyMigrated);
204
+ if (hasAnyMigrated) {
205
+ return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
206
+ }
207
+
208
+ const legacyPhases = parsedPhases.filter(e => !e.alreadyMigrated);
209
+ const idMapping = assignSubIndices(legacyPhases);
210
+
211
+ // Secondary lookup: (milestoneInt, normalizedLegacyNum) → newId
212
+ // Used for directory renames and checklist rewrites where line position is unknown.
213
+ // For simplicity, each milestone gets its own Map from legacy num → newId.
214
+ const milestoneIdMap = new Map(); // milestoneInt → Map<normalizedLegacyNum, newId>
215
+ for (const [, entry] of idMapping) {
216
+ if (!milestoneIdMap.has(entry.milestoneInt)) {
217
+ milestoneIdMap.set(entry.milestoneInt, new Map());
218
+ }
219
+ const mMap = milestoneIdMap.get(entry.milestoneInt);
220
+ const legacyNum = entry.legacyPhaseNum;
221
+ // Register integer forms (covers plain numeric and letter-suffix IDs)
222
+ const intPart = parseInt(legacyNum, 10);
223
+ const paddedLegacy = String(intPart).padStart(2, '0');
224
+ const unpaddedLegacy = String(intPart);
225
+ mMap.set(paddedLegacy, entry.newId);
226
+ mMap.set(unpaddedLegacy, entry.newId);
227
+ // Also register the original form and padded-integer+decimal form
228
+ // so decimal IDs like "2.1" / "02.1" round-trip correctly.
229
+ mMap.set(legacyNum, entry.newId);
230
+ const dotIdx = legacyNum.indexOf('.');
231
+ if (dotIdx !== -1) {
232
+ const decimalSuffix = legacyNum.slice(dotIdx); // e.g. ".1"
233
+ mMap.set(paddedLegacy + decimalSuffix, entry.newId);
234
+ mMap.set(unpaddedLegacy + decimalSuffix, entry.newId);
235
+ }
236
+ }
237
+
238
+ // ── Read existing phase directories ───────────────────────────────────────
239
+ let existingDirs = [];
240
+ try {
241
+ existingDirs = fs.readdirSync(phasesDir).filter(d => {
242
+ try {
243
+ return fs.statSync(path.join(phasesDir, d)).isDirectory();
244
+ } catch { return false; }
245
+ });
246
+ } catch { /* phases dir may not exist */ }
247
+
248
+ // ── Build phase rename pairs ───────────────────────────────────────────────
249
+ // Flat ordered list of (legacyPhaseNum, newId) in ROADMAP order, for dir matching.
250
+ const orderedMappings = [...idMapping.values()].map(e => ({
251
+ legacyPhaseNum: e.legacyPhaseNum,
252
+ newId: e.newId,
253
+ milestoneInt: e.milestoneInt,
254
+ _used: false,
255
+ }));
256
+
257
+ // Note: if the same legacy phase number appears in multiple milestones (the exact legacy
258
+ // ambiguity this tool is designed to resolve), directories are matched in ROADMAP document
259
+ // order — the first ROADMAP occurrence of a given number claims the first matching disk dir.
260
+ // This is the only unambiguous assignment strategy for flat dirs that carry no milestone
261
+ // context. The dry-run output shows the complete rename plan so users can review before
262
+ // applying with --apply.
263
+
264
+ const phases = [];
265
+ for (const dirName of existingDirs) {
266
+ const phaseNum = extractPhaseNumFromDir(dirName);
267
+ if (!phaseNum) continue;
268
+
269
+ const intPart = parseInt(phaseNum, 10);
270
+ const paddedPhaseNum = String(intPart).padStart(2, '0');
271
+ const unpaddedPhaseNum = String(intPart);
272
+ // For decimal IDs like "02.1", also try "2.1"
273
+ const dotIdx = phaseNum.indexOf('.');
274
+ const decimalUnpadded = dotIdx !== -1 ? unpaddedPhaseNum + phaseNum.slice(dotIdx) : null;
275
+
276
+ // Find the first unused mapping whose legacy number matches (exact, padded, unpadded, or decimal)
277
+ const found = orderedMappings.find(m => !m._used && (
278
+ m.legacyPhaseNum === phaseNum ||
279
+ m.legacyPhaseNum === paddedPhaseNum ||
280
+ m.legacyPhaseNum === unpaddedPhaseNum ||
281
+ (decimalUnpadded && m.legacyPhaseNum === decimalUnpadded)
282
+ ));
283
+ if (!found) continue;
284
+ found._used = true;
285
+
286
+ const newDirName = buildNewDirName(dirName, found.newId, projectCode);
287
+ if (newDirName !== dirName) {
288
+ phases.push({
289
+ oldId: phaseNum,
290
+ newId: found.newId,
291
+ oldDir: dirName,
292
+ newDir: newDirName,
293
+ });
294
+ }
295
+ }
296
+
297
+ // ── Build ROADMAP.md line edits ────────────────────────────────────────────
298
+ const roadmapEdits = [];
299
+
300
+ for (const entry of legacyPhases) {
301
+ // Use lineIndex as the canonical key (not legacyPhaseNum, which may collide across milestones)
302
+ const mapping = idMapping.get(entry.lineIndex);
303
+ if (!mapping) continue;
304
+
305
+ // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
306
+ const oldLine = lines[entry.lineIndex];
307
+ const newLine = oldLine.replace(
308
+ /^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i,
309
+ `$1${mapping.newId}$2`
310
+ );
311
+ if (newLine !== oldLine) {
312
+ roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
313
+ }
314
+ }
315
+
316
+ // Rewrite checklist lines in ROADMAP.md — use milestone context to resolve collisions.
317
+ let currentChecklistMilestone = null;
318
+ for (let i = 0; i < lines.length; i++) {
319
+ const line = lines[i];
320
+
321
+ // Track enclosing milestone section for context-aware lookup
322
+ const milestoneHeadingMatch = line.match(MILESTONE_HEADING_RE);
323
+ if (milestoneHeadingMatch) {
324
+ currentChecklistMilestone = parseInt(milestoneHeadingMatch[1], 10);
325
+ }
326
+
327
+ // Already in roadmapEdits? skip
328
+ if (roadmapEdits.some(e => e.lineIndex === i)) continue;
329
+
330
+ // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
331
+ const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
332
+ if (checklistMatch) {
333
+ const legacyNum = checklistMatch[2];
334
+ const cIntPart = parseInt(legacyNum, 10);
335
+ const paddedLegacy = String(cIntPart).padStart(2, '0');
336
+ const unpaddedLegacy = String(cIntPart);
337
+ const cDotIdx = legacyNum.indexOf('.');
338
+ const paddedLegacyDecimal = cDotIdx !== -1 ? paddedLegacy + legacyNum.slice(cDotIdx) : null;
339
+
340
+ // Prefer milestone-context lookup (avoids collision across milestones)
341
+ let newId;
342
+ if (currentChecklistMilestone !== null && milestoneIdMap.has(currentChecklistMilestone)) {
343
+ const mMap = milestoneIdMap.get(currentChecklistMilestone);
344
+ newId = mMap.get(legacyNum) || mMap.get(paddedLegacy) || mMap.get(unpaddedLegacy);
345
+ if (!newId && paddedLegacyDecimal) newId = mMap.get(paddedLegacyDecimal);
346
+ }
347
+ if (!newId) {
348
+ // Fallback: use ordered flat list (no milestone collision in this roadmap)
349
+ const found = orderedMappings.find(m =>
350
+ m.legacyPhaseNum === legacyNum ||
351
+ m.legacyPhaseNum === paddedLegacy ||
352
+ m.legacyPhaseNum === unpaddedLegacy ||
353
+ (paddedLegacyDecimal && m.legacyPhaseNum === paddedLegacyDecimal)
354
+ );
355
+ if (found) newId = found.newId;
356
+ }
357
+
358
+ if (newId) {
359
+ const newLine = line.replace(
360
+ /^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i,
361
+ `$1${newId}$2`
362
+ );
363
+ if (newLine !== line) {
364
+ roadmapEdits.push({ lineIndex: i, from: line, to: newLine });
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // ── Build cross-ref edits for STATE.md and PROJECT.md ────────────────────
371
+ const crossRefEdits = [];
372
+ const crossRefFiles = ['STATE.md', 'PROJECT.md'];
373
+
374
+ for (const fileName of crossRefFiles) {
375
+ const filePath = path.join(pDir, fileName);
376
+ if (!fs.existsSync(filePath)) continue;
377
+
378
+ const fileContent = fs.readFileSync(filePath, 'utf8');
379
+
380
+ // Iterate using orderedMappings (ROADMAP order) — idMapping is now keyed by lineIndex.
381
+ for (const m of orderedMappings) {
382
+ const legacyNum = m.legacyPhaseNum;
383
+ const xIntPart = parseInt(legacyNum, 10);
384
+ const paddedNum = String(xIntPart).padStart(2, '0');
385
+ const unpaddedNum = String(xIntPart);
386
+ // Decimal suffix (e.g. ".1" from "2.1") — preserve in cross-ref patterns
387
+ const xDotIdx = legacyNum.indexOf('.');
388
+ const decimalSuffix = xDotIdx !== -1 ? legacyNum.slice(xDotIdx) : '';
389
+
390
+ // Rewrite project_code-prefixed references: "GSD-01-" → "GSD-01-02-"
391
+ if (projectCode) {
392
+ const [milestoneStr, subStr] = m.newId.split('-');
393
+ const paddedMilestone = String(parseInt(milestoneStr, 10)).padStart(2, '0');
394
+ const prefixedNew = `${projectCode}-${paddedMilestone}-${subStr}-`;
395
+ // Try both padded and original forms as old prefix
396
+ for (const oldNum of new Set([paddedNum + decimalSuffix, unpaddedNum + decimalSuffix, paddedNum, unpaddedNum])) {
397
+ const prefixedOld = `${projectCode}-${oldNum}-`;
398
+ if (fileContent.includes(prefixedOld)) {
399
+ crossRefEdits.push({ file: fileName, from: prefixedOld, to: prefixedNew });
400
+ }
401
+ }
402
+ }
403
+
404
+ // Rewrite prose references: "Phase 1:" → "Phase 1-01:", "Phase 2.1:" → "Phase 1-02:"
405
+ const proseOldPatterns = new Set([
406
+ `Phase ${unpaddedNum}${decimalSuffix}:`,
407
+ `Phase ${paddedNum}${decimalSuffix}:`,
408
+ `Phase ${legacyNum}:`,
409
+ ]);
410
+ for (const proseOld of proseOldPatterns) {
411
+ if (fileContent.includes(proseOld)) {
412
+ const proseNew = `Phase ${m.newId}:`;
413
+ crossRefEdits.push({ file: fileName, from: proseOld, to: proseNew });
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ return {
420
+ alreadyMigrated: false,
421
+ phases,
422
+ roadmapEdits,
423
+ crossRefEdits,
424
+ };
425
+ }
426
+
427
+ // ─── applyMigration ───────────────────────────────────────────────────────────
428
+
429
+ /**
430
+ * Apply the migration plan computed by computeMigrationPlan().
431
+ *
432
+ * @param {string} cwd
433
+ * @param {{ alreadyMigrated, phases, roadmapEdits, crossRefEdits }} plan
434
+ * @param {object} [options]
435
+ * @param {boolean} [options.dryRun=true] - Print plan and exit without mutating.
436
+ * @returns {{
437
+ * applied?: boolean,
438
+ * alreadyMigrated?: boolean,
439
+ * renamedDirs?: string[],
440
+ * editedFiles?: string[],
441
+ * }}
442
+ */
443
+ function applyMigration(cwd, plan, options = {}) {
444
+ const dryRun = options.dryRun !== false; // default true
445
+
446
+ if (plan.alreadyMigrated) {
447
+ return { alreadyMigrated: true };
448
+ }
449
+
450
+ if (dryRun) {
451
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
452
+ return { dryRun: true };
453
+ }
454
+
455
+ // ── Real run: verify clean working tree ───────────────────────────────────
456
+ let gitStatus;
457
+ try {
458
+ gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8' });
459
+ } catch (err) {
460
+ throw new Error(`git status failed: ${err.message}`);
461
+ }
462
+ if (gitStatus.trim().length > 0) {
463
+ throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
464
+ }
465
+
466
+ // Capture HEAD sha for rollback
467
+ let headSha;
468
+ try {
469
+ headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
470
+ } catch (err) {
471
+ throw new Error(`git rev-parse HEAD failed: ${err.message}`);
472
+ }
473
+
474
+ const pDir = planningDir(cwd);
475
+ const phasesDir = path.join(pDir, 'phases');
476
+ const roadmapPath = path.join(pDir, 'ROADMAP.md');
477
+ const configPath = path.join(pDir, 'config.json');
478
+
479
+ const renamedDirs = [];
480
+ const editedFiles = [];
481
+
482
+ try {
483
+ // 1. Rename phase directories
484
+ for (const phaseEntry of plan.phases) {
485
+ const oldPath = path.join(phasesDir, phaseEntry.oldDir);
486
+ const newPath = path.join(phasesDir, phaseEntry.newDir);
487
+ if (fs.existsSync(oldPath)) {
488
+ fs.renameSync(oldPath, newPath);
489
+ renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
490
+ }
491
+ }
492
+
493
+ // 2. Rewrite ROADMAP.md phase headings
494
+ if (plan.roadmapEdits.length > 0) {
495
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
496
+ const lines = roadmapContent.split('\n');
497
+
498
+ // Sort edits by lineIndex to apply in order
499
+ const sortedEdits = [...plan.roadmapEdits].sort((a, b) => a.lineIndex - b.lineIndex);
500
+ for (const edit of sortedEdits) {
501
+ if (lines[edit.lineIndex] === edit.from) {
502
+ lines[edit.lineIndex] = edit.to;
503
+ }
504
+ }
505
+
506
+ fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
507
+ editedFiles.push('ROADMAP.md');
508
+ }
509
+
510
+ // 3. Rewrite cross-refs in STATE.md and PROJECT.md
511
+ const crossRefsByFile = new Map();
512
+ for (const edit of plan.crossRefEdits) {
513
+ if (!crossRefsByFile.has(edit.file)) {
514
+ crossRefsByFile.set(edit.file, []);
515
+ }
516
+ crossRefsByFile.get(edit.file).push(edit);
517
+ }
518
+
519
+ for (const [fileName, edits] of crossRefsByFile) {
520
+ const filePath = path.join(pDir, fileName);
521
+ if (!fs.existsSync(filePath)) continue;
522
+
523
+ let content = fs.readFileSync(filePath, 'utf8');
524
+ let changed = false;
525
+
526
+ for (const edit of edits) {
527
+ if (content.includes(edit.from)) {
528
+ // Replace all occurrences
529
+ content = content.split(edit.from).join(edit.to);
530
+ changed = true;
531
+ }
532
+ }
533
+
534
+ if (changed) {
535
+ fs.writeFileSync(filePath, content, 'utf8');
536
+ editedFiles.push(fileName);
537
+ }
538
+ }
539
+
540
+ // 4. Update config.json: set phase_id_convention to 'milestone-prefixed'
541
+ let configData = {};
542
+ try {
543
+ configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
544
+ } catch { /* config may not exist yet */ }
545
+
546
+ configData.phase_id_convention = 'milestone-prefixed';
547
+ fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
548
+ editedFiles.push('config.json');
549
+
550
+ } catch (err) {
551
+ // Rollback via git reset --hard + git clean
552
+ try {
553
+ execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' });
554
+ execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' });
555
+ } catch (rollbackErr) {
556
+ // Swallow rollback errors — surface original error
557
+ }
558
+ throw new Error(`Migration failed (rolled back to ${headSha}): ${err.message}`);
559
+ }
560
+
561
+ return { applied: true, renamedDirs, editedFiles };
562
+ }
563
+
564
+ // ─── Exports ──────────────────────────────────────────────────────────────────
565
+
566
+ module.exports = {
567
+ computeMigrationPlan,
568
+ applyMigration,
569
+ };
@@ -63,7 +63,7 @@ function countPhasePlansAndSummaries(phaseDir) {
63
63
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
64
64
  // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
65
65
  const phasePattern = new RegExp(
66
- `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
66
+ `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
67
67
  'i'
68
68
  );
69
69
  const headerMatch = content.match(phasePattern);
@@ -92,9 +92,10 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
92
92
  const phaseName = headerMatch[1].trim();
93
93
  const headerIndex = headerMatch.index;
94
94
 
95
- // Find the end of this section (next ## or ### phase header, or end of file)
95
+ // Find the end of this section (next ## or ### phase header, or end of file).
96
+ // Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:.
96
97
  const restOfContent = content.slice(headerIndex);
97
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w][\w.-]*/i);
98
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i);
98
99
  const sectionEnd = nextHeaderMatch
99
100
  ? headerIndex + nextHeaderMatch.index
100
101
  : content.length;
@@ -203,7 +204,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
203
204
  const phasesDir = planningPaths(cwd).phases;
204
205
 
205
206
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
206
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
207
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
207
208
  const phases = [];
208
209
  let match;
209
210
 
@@ -225,7 +226,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
225
226
  const restOfContent = content.slice(sectionStart);
226
227
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
227
228
  // recognised as section boundaries.
228
- const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
229
+ const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
229
230
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
230
231
  const section = content.slice(sectionStart, sectionEnd);
231
232
 
@@ -1,35 +1,42 @@
1
- 'use strict';
2
-
1
+ "use strict";
2
+ /**
3
+ * Shared semver comparison utility (ADR-457 pilot: first hand-written
4
+ * bin/lib/*.cjs collapsed to a TypeScript source of truth).
5
+ *
6
+ * Logic is preserved byte-for-behaviour from the prior hand-written
7
+ * `get-shit-done/bin/lib/semver-compare.cjs`; only types are added. The
8
+ * normalization policy here is locked by `tests/semver-compare.test.cjs` and
9
+ * consumed by update-check, statusline dev-install detection, and changeset
10
+ * range compare (`scripts/changeset/cli.cjs`).
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.toNumericTuple = toNumericTuple;
14
+ exports.compareSemverCore = compareSemverCore;
15
+ exports.isSemverNewer = isSemverNewer;
16
+ exports.isStableTripletSemver = isStableTripletSemver;
3
17
  function toNumericTuple(input) {
4
- const cleaned = String(input == null ? '' : input).trim().replace(/^v/, '');
5
- const base = cleaned.replace(/[-+].*$/, '');
6
- const parts = base.split('.');
7
- const major = Number.parseInt(parts[0], 10) || 0;
8
- const minor = Number.parseInt(parts[1], 10) || 0;
9
- const patch = Number.parseInt(parts[2], 10) || 0;
10
- return [major, minor, patch];
18
+ const cleaned = String(input == null ? '' : input).trim().replace(/^v/, '');
19
+ const base = cleaned.replace(/[-+].*$/, '');
20
+ const parts = base.split('.');
21
+ const major = Number.parseInt(parts[0], 10) || 0;
22
+ const minor = Number.parseInt(parts[1], 10) || 0;
23
+ const patch = Number.parseInt(parts[2], 10) || 0;
24
+ return [major, minor, patch];
11
25
  }
12
-
13
26
  function compareSemverCore(a, b) {
14
- const [a0, a1, a2] = toNumericTuple(a);
15
- const [b0, b1, b2] = toNumericTuple(b);
16
- if (a0 !== b0) return a0 > b0 ? 1 : -1;
17
- if (a1 !== b1) return a1 > b1 ? 1 : -1;
18
- if (a2 !== b2) return a2 > b2 ? 1 : -1;
19
- return 0;
27
+ const [a0, a1, a2] = toNumericTuple(a);
28
+ const [b0, b1, b2] = toNumericTuple(b);
29
+ if (a0 !== b0)
30
+ return a0 > b0 ? 1 : -1;
31
+ if (a1 !== b1)
32
+ return a1 > b1 ? 1 : -1;
33
+ if (a2 !== b2)
34
+ return a2 > b2 ? 1 : -1;
35
+ return 0;
20
36
  }
21
-
22
37
  function isSemverNewer(a, b) {
23
- return compareSemverCore(a, b) > 0;
38
+ return compareSemverCore(a, b) > 0;
24
39
  }
25
-
26
40
  function isStableTripletSemver(v) {
27
- return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
41
+ return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
28
42
  }
29
-
30
- module.exports = {
31
- compareSemverCore,
32
- isSemverNewer,
33
- isStableTripletSemver,
34
- toNumericTuple,
35
- };