diff --git a/commands/gsd/from-gsd2.md b/commands/gsd/from-gsd2.md
new file mode 100644
index 00000000..5bfc4e48
--- /dev/null
+++ b/commands/gsd/from-gsd2.md
@@ -0,0 +1,45 @@
+---
+name: gsd:from-gsd2
+description: Import a GSD-2 (.gsd/) project back to GSD v1 (.planning/) format
+argument-hint: "[--path
] [--force]"
+allowed-tools:
+ - Read
+ - Write
+ - Bash
+type: prompt
+---
+
+
+Reverse-migrate a GSD-2 project (`.gsd/` directory) back to GSD v1 (`.planning/`) format.
+
+Maps the GSD-2 hierarchy (Milestone → Slice → Task) to the GSD v1 hierarchy (Milestone sections in ROADMAP.md → Phase → Plan), preserving completion state, research files, and summaries.
+
+
+
+
+1. **Locate the .gsd/ directory** — check the current working directory (or `--path` argument):
+ ```bash
+ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" from-gsd2 --dry-run
+ ```
+ If no `.gsd/` is found, report the error and stop.
+
+2. **Show the dry-run preview** — present the full file list and migration statistics to the user. Ask for confirmation before writing anything.
+
+3. **Run the migration** after confirmation:
+ ```bash
+ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" from-gsd2
+ ```
+ Use `--force` if `.planning/` already exists and the user has confirmed overwrite.
+
+4. **Report the result** — show the `filesWritten` count, `planningDir` path, and the preview summary.
+
+
+
+
+- The migration is non-destructive: `.gsd/` is never modified or removed.
+- Pass `--path ` to migrate a project at a different path than the current directory.
+- Slices are numbered sequentially across all milestones (M001/S01 → phase 01, M001/S02 → phase 02, M002/S01 → phase 03, etc.).
+- Tasks within each slice become plans (T01 → plan 01, T02 → plan 02, etc.).
+- Completed slices and tasks carry their done state into ROADMAP.md checkboxes and SUMMARY.md files.
+- GSD-2 cost/token ledger, database state, and VS Code extension state cannot be migrated.
+
diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs
index d0bfb19f..540905ff 100755
--- a/get-shit-done/bin/gsd-tools.cjs
+++ b/get-shit-done/bin/gsd-tools.cjs
@@ -154,6 +154,10 @@
* learnings copy Copy from current project's LEARNINGS.md
* learnings prune --older-than Remove entries older than duration (e.g. 90d)
* learnings delete Delete a learning by ID
+ *
+ * GSD-2 Migration:
+ * from-gsd2 [--path ] [--force] [--dry-run]
+ * Import a GSD-2 (.gsd/) project back to GSD v1 (.planning/) format
*/
const fs = require('fs');
@@ -1070,6 +1074,14 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
+ // ─── GSD-2 Reverse Migration ───────────────────────────────────────────
+
+ case 'from-gsd2': {
+ const gsd2Import = require('./lib/gsd2-import.cjs');
+ gsd2Import.cmdFromGsd2(args.slice(1), cwd, raw);
+ break;
+ }
+
default:
error(`Unknown command: ${command}`);
}
diff --git a/get-shit-done/bin/lib/gsd2-import.cjs b/get-shit-done/bin/lib/gsd2-import.cjs
new file mode 100644
index 00000000..26dea66f
--- /dev/null
+++ b/get-shit-done/bin/lib/gsd2-import.cjs
@@ -0,0 +1,511 @@
+'use strict';
+
+/**
+ * gsd2-import — Reverse migration from GSD-2 (.gsd/) to GSD v1 (.planning/)
+ *
+ * Reads a GSD-2 project directory structure and produces a complete
+ * .planning/ artifact tree in GSD v1 format.
+ *
+ * GSD-2 hierarchy: Milestone → Slice → Task
+ * GSD v1 hierarchy: Milestone (in ROADMAP.md) → Phase → Plan
+ *
+ * Mapping rules:
+ * - Slices are numbered sequentially across all milestones (01, 02, …)
+ * - Tasks within a slice become plans (01-01, 01-02, …)
+ * - Completed slices ([x] in ROADMAP) → [x] phases in ROADMAP.md
+ * - Tasks with a SUMMARY file → SUMMARY.md written
+ * - Slice RESEARCH.md → phase XX-RESEARCH.md
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+// ─── Utilities ──────────────────────────────────────────────────────────────
+
+function readOptional(filePath) {
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
+}
+
+function zeroPad(n, width = 2) {
+ return String(n).padStart(width, '0');
+}
+
+function slugify(title) {
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+}
+
+// ─── GSD-2 Parser ───────────────────────────────────────────────────────────
+
+/**
+ * Find the .gsd/ directory starting from a project root.
+ * Returns the absolute path or null if not found.
+ */
+function findGsd2Root(startPath) {
+ if (path.basename(startPath) === '.gsd' && fs.existsSync(startPath)) {
+ return startPath;
+ }
+ const candidate = path.join(startPath, '.gsd');
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
+ return candidate;
+ }
+ return null;
+}
+
+/**
+ * Parse the ## Slices section from a GSD-2 milestone ROADMAP.md.
+ * Each slice entry looks like:
+ * - [x] **S01: Title** `risk:medium` `depends:[S00]`
+ */
+function parseSlicesFromRoadmap(content) {
+ const slices = [];
+ const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/);
+ if (!sectionMatch) return slices;
+
+ for (const line of sectionMatch[1].split('\n')) {
+ const m = line.match(/^- \[([x ])\]\s+\*\*(\w+):\s*([^*]+)\*\*/);
+ if (!m) continue;
+ slices.push({ done: m[1] === 'x', id: m[2].trim(), title: m[3].trim() });
+ }
+ return slices;
+}
+
+/**
+ * Parse the milestone title from the first heading in a GSD-2 ROADMAP.md.
+ * Format: # M001: Title
+ */
+function parseMilestoneTitle(content) {
+ const m = content.match(/^# \w+:\s*(.+)/m);
+ return m ? m[1].trim() : null;
+}
+
+/**
+ * Parse a task title from a GSD-2 T##-PLAN.md.
+ * Format: # T01: Title
+ */
+function parseTaskTitle(content, fallback) {
+ const m = content.match(/^# \w+:\s*(.+)/m);
+ return m ? m[1].trim() : fallback;
+}
+
+/**
+ * Parse the ## Description body from a GSD-2 task plan.
+ */
+function parseTaskDescription(content) {
+ const m = content.match(/## Description\n+([\s\S]+?)(?:\n## |\n# |$)/);
+ return m ? m[1].trim() : '';
+}
+
+/**
+ * Parse ## Must-Haves items from a GSD-2 task plan.
+ */
+function parseTaskMustHaves(content) {
+ const m = content.match(/## Must-Haves\n+([\s\S]+?)(?:\n## |\n# |$)/);
+ if (!m) return [];
+ return m[1].split('\n')
+ .map(l => l.match(/^- \[[ x]\]\s*(.+)/))
+ .filter(Boolean)
+ .map(match => match[1].trim());
+}
+
+/**
+ * Read all task plan files from a GSD-2 tasks/ directory.
+ */
+function readTasksDir(tasksDir) {
+ if (!fs.existsSync(tasksDir)) return [];
+
+ return fs.readdirSync(tasksDir)
+ .filter(f => f.endsWith('-PLAN.md'))
+ .sort()
+ .map(tf => {
+ const tid = tf.replace('-PLAN.md', '');
+ const plan = readOptional(path.join(tasksDir, tf));
+ const summary = readOptional(path.join(tasksDir, `${tid}-SUMMARY.md`));
+ return {
+ id: tid,
+ title: plan ? parseTaskTitle(plan, tid) : tid,
+ description: plan ? parseTaskDescription(plan) : '',
+ mustHaves: plan ? parseTaskMustHaves(plan) : [],
+ plan,
+ summary,
+ done: !!summary,
+ };
+ });
+}
+
+/**
+ * Parse a complete GSD-2 .gsd/ directory into a structured representation.
+ */
+function parseGsd2(gsdDir) {
+ const data = {
+ projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')),
+ requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')),
+ milestones: [],
+ };
+
+ const milestonesBase = path.join(gsdDir, 'milestones');
+ if (!fs.existsSync(milestonesBase)) return data;
+
+ const milestoneIds = fs.readdirSync(milestonesBase)
+ .filter(d => fs.statSync(path.join(milestonesBase, d)).isDirectory())
+ .sort();
+
+ for (const mid of milestoneIds) {
+ const mDir = path.join(milestonesBase, mid);
+ const roadmapContent = readOptional(path.join(mDir, `${mid}-ROADMAP.md`));
+ const slicesDir = path.join(mDir, 'slices');
+
+ const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : [];
+
+ const slices = sliceInfos.map(info => {
+ const sDir = path.join(slicesDir, info.id);
+ const hasSDir = fs.existsSync(sDir);
+ return {
+ id: info.id,
+ title: info.title,
+ done: info.done,
+ plan: hasSDir ? readOptional(path.join(sDir, `${info.id}-PLAN.md`)) : null,
+ summary: hasSDir ? readOptional(path.join(sDir, `${info.id}-SUMMARY.md`)) : null,
+ research: hasSDir ? readOptional(path.join(sDir, `${info.id}-RESEARCH.md`)) : null,
+ context: hasSDir ? readOptional(path.join(sDir, `${info.id}-CONTEXT.md`)) : null,
+ tasks: hasSDir ? readTasksDir(path.join(sDir, 'tasks')) : [],
+ };
+ });
+
+ data.milestones.push({
+ id: mid,
+ title: roadmapContent ? (parseMilestoneTitle(roadmapContent) ?? mid) : mid,
+ research: readOptional(path.join(mDir, `${mid}-RESEARCH.md`)),
+ slices,
+ });
+ }
+
+ return data;
+}
+
+// ─── Artifact Builders ──────────────────────────────────────────────────────
+
+/**
+ * Build a GSD v1 PLAN.md from a GSD-2 task.
+ */
+function buildPlanMd(task, phasePrefix, planPrefix, phaseSlug, milestoneTitle) {
+ const lines = [
+ '---',
+ `phase: "${phasePrefix}"`,
+ `plan: "${planPrefix}"`,
+ 'type: "implementation"',
+ '---',
+ '',
+ '',
+ task.title,
+ '',
+ '',
+ '',
+ `Phase: ${phasePrefix} (${phaseSlug}) — Milestone: ${milestoneTitle}`,
+ ];
+
+ if (task.description) {
+ lines.push('', task.description);
+ }
+
+ lines.push('');
+
+ if (task.mustHaves.length > 0) {
+ lines.push('', '');
+ for (const mh of task.mustHaves) {
+ lines.push(`- ${mh}`);
+ }
+ lines.push('');
+ }
+
+ return lines.join('\n') + '\n';
+}
+
+/**
+ * Build a GSD v1 SUMMARY.md from a GSD-2 task summary.
+ * Strips the GSD-2 frontmatter and preserves the body.
+ */
+function buildSummaryMd(task, phasePrefix, planPrefix) {
+ const raw = task.summary || '';
+ // Strip GSD-2 frontmatter block (--- ... ---) if present
+ const bodyMatch = raw.match(/^---[\s\S]*?---\n+([\s\S]*)$/);
+ const body = bodyMatch ? bodyMatch[1].trim() : raw.trim();
+
+ return [
+ '---',
+ `phase: "${phasePrefix}"`,
+ `plan: "${planPrefix}"`,
+ '---',
+ '',
+ body || 'Task completed (migrated from GSD-2).',
+ '',
+ ].join('\n');
+}
+
+/**
+ * Build a GSD v1 XX-CONTEXT.md from a GSD-2 slice.
+ */
+function buildContextMd(slice, phasePrefix) {
+ const lines = [
+ `# Phase ${phasePrefix} Context`,
+ '',
+ `Migrated from GSD-2 slice ${slice.id}: ${slice.title}`,
+ ];
+
+ const extra = slice.context || '';
+ if (extra.trim()) {
+ lines.push('', extra.trim());
+ }
+
+ return lines.join('\n') + '\n';
+}
+
+/**
+ * Build the GSD v1 ROADMAP.md with milestone-sectioned format.
+ */
+function buildRoadmapMd(milestones, phaseMap) {
+ const lines = ['# Roadmap', ''];
+
+ for (const milestone of milestones) {
+ lines.push(`## ${milestone.id}: ${milestone.title}`, '');
+ const mPhases = phaseMap.filter(p => p.milestoneId === milestone.id);
+ for (const { slice, phaseNum } of mPhases) {
+ const prefix = zeroPad(phaseNum);
+ const slug = slugify(slice.title);
+ const check = slice.done ? 'x' : ' ';
+ lines.push(`- [${check}] **Phase ${prefix}: ${slug}** — ${slice.title}`);
+ }
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Build the GSD v1 STATE.md reflecting the current position in the project.
+ */
+function buildStateMd(phaseMap) {
+ const currentEntry = phaseMap.find(p => !p.slice.done);
+ const totalPhases = phaseMap.length;
+ const donePhases = phaseMap.filter(p => p.slice.done).length;
+ const pct = totalPhases > 0 ? Math.round((donePhases / totalPhases) * 100) : 0;
+
+ const currentPhaseNum = currentEntry ? zeroPad(currentEntry.phaseNum) : zeroPad(totalPhases);
+ const currentSlug = currentEntry ? slugify(currentEntry.slice.title) : 'complete';
+ const status = currentEntry ? 'Ready to plan' : 'All phases complete';
+
+ const filled = Math.round(pct / 10);
+ const bar = `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}]`;
+ const today = new Date().toISOString().split('T')[0];
+
+ return [
+ '# Project State',
+ '',
+ '## Project Reference',
+ '',
+ 'See: .planning/PROJECT.md',
+ '',
+ `**Current focus:** Phase ${currentPhaseNum} (${currentSlug})`,
+ '',
+ '## Current Position',
+ '',
+ `Phase: ${currentPhaseNum} of ${zeroPad(totalPhases)} (${currentSlug})`,
+ `Status: ${status}`,
+ `Last activity: ${today} — Migrated from GSD-2`,
+ '',
+ `Progress: ${bar} ${pct}%`,
+ '',
+ '## Accumulated Context',
+ '',
+ '### Decisions',
+ '',
+ 'Migrated from GSD-2. Review PROJECT.md for key decisions.',
+ '',
+ '### Blockers/Concerns',
+ '',
+ 'None.',
+ '',
+ '## Session Continuity',
+ '',
+ `Last session: ${today}`,
+ 'Stopped at: Migration from GSD-2 completed',
+ 'Resume file: None',
+ '',
+ ].join('\n');
+}
+
+// ─── Transformer ─────────────────────────────────────────────────────────────
+
+/**
+ * Convert parsed GSD-2 data into a map of relative path → file content.
+ * All paths are relative to the .planning/ root.
+ */
+function buildPlanningArtifacts(gsd2Data) {
+ const artifacts = new Map();
+
+ // Passthrough files
+ artifacts.set('PROJECT.md', gsd2Data.projectContent || '# Project\n\n(Migrated from GSD-2)\n');
+ if (gsd2Data.requirements) {
+ artifacts.set('REQUIREMENTS.md', gsd2Data.requirements);
+ }
+
+ // Minimal valid v1 config
+ artifacts.set('config.json', JSON.stringify({ version: 1 }, null, 2) + '\n');
+
+ // Build sequential phase map: flatten Milestones → Slices into numbered phases
+ const phaseMap = [];
+ let phaseNum = 1;
+ for (const milestone of gsd2Data.milestones) {
+ for (const slice of milestone.slices) {
+ phaseMap.push({ milestoneId: milestone.id, milestoneTitle: milestone.title, slice, phaseNum });
+ phaseNum++;
+ }
+ }
+
+ artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap));
+ artifacts.set('STATE.md', buildStateMd(phaseMap));
+
+ for (const { slice, phaseNum, milestoneTitle } of phaseMap) {
+ const prefix = zeroPad(phaseNum);
+ const slug = slugify(slice.title);
+ const dir = `phases/${prefix}-${slug}`;
+
+ artifacts.set(`${dir}/${prefix}-CONTEXT.md`, buildContextMd(slice, prefix));
+
+ if (slice.research) {
+ artifacts.set(`${dir}/${prefix}-RESEARCH.md`, slice.research);
+ }
+
+ for (let i = 0; i < slice.tasks.length; i++) {
+ const task = slice.tasks[i];
+ const planPrefix = zeroPad(i + 1);
+
+ artifacts.set(
+ `${dir}/${prefix}-${planPrefix}-PLAN.md`,
+ buildPlanMd(task, prefix, planPrefix, slug, milestoneTitle)
+ );
+
+ if (task.done && task.summary) {
+ artifacts.set(
+ `${dir}/${prefix}-${planPrefix}-SUMMARY.md`,
+ buildSummaryMd(task, prefix, planPrefix)
+ );
+ }
+ }
+ }
+
+ return artifacts;
+}
+
+// ─── Preview ─────────────────────────────────────────────────────────────────
+
+/**
+ * Format a dry-run preview string for display before writing.
+ */
+function buildPreview(gsd2Data, artifacts) {
+ const lines = ['Preview — files that will be created in .planning/:'];
+
+ for (const rel of artifacts.keys()) {
+ lines.push(` ${rel}`);
+ }
+
+ const totalSlices = gsd2Data.milestones.reduce((s, m) => s + m.slices.length, 0);
+ const doneSlices = gsd2Data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
+ const allTasks = gsd2Data.milestones.flatMap(m => m.slices.flatMap(sl => sl.tasks));
+ const doneTasks = allTasks.filter(t => t.done).length;
+
+ lines.push('');
+ lines.push(`Milestones: ${gsd2Data.milestones.length}`);
+ lines.push(`Phases (slices): ${totalSlices} (${doneSlices} completed)`);
+ lines.push(`Plans (tasks): ${allTasks.length} (${doneTasks} completed)`);
+ lines.push('');
+ lines.push('Cannot migrate automatically:');
+ lines.push(' - GSD-2 cost/token ledger (no v1 equivalent)');
+ lines.push(' - GSD-2 database state (rebuilt from files on first /gsd-health)');
+ lines.push(' - VS Code extension state');
+
+ return lines.join('\n');
+}
+
+// ─── Writer ───────────────────────────────────────────────────────────────────
+
+/**
+ * Write all artifacts to the .planning/ directory.
+ */
+function writePlanningDir(artifacts, planningRoot) {
+ for (const [rel, content] of artifacts) {
+ const absPath = path.join(planningRoot, rel);
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
+ fs.writeFileSync(absPath, content, 'utf8');
+ }
+}
+
+// ─── Command Handler ──────────────────────────────────────────────────────────
+
+/**
+ * Entry point called from gsd-tools.cjs.
+ * Supports: --force, --dry-run, --path
+ */
+function cmdFromGsd2(args, cwd, raw) {
+ const { output, error } = require('./core.cjs');
+
+ const force = args.includes('--force');
+ const dryRun = args.includes('--dry-run');
+
+ const pathIdx = args.indexOf('--path');
+ const projectDir = pathIdx >= 0 && args[pathIdx + 1]
+ ? path.resolve(cwd, args[pathIdx + 1])
+ : cwd;
+
+ const gsdDir = findGsd2Root(projectDir);
+ if (!gsdDir) {
+ return output({ success: false, error: `No .gsd/ directory found in ${projectDir}` }, raw);
+ }
+
+ const planningRoot = path.join(path.dirname(gsdDir), '.planning');
+ if (fs.existsSync(planningRoot) && !force) {
+ return output({
+ success: false,
+ error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`,
+ }, raw);
+ }
+
+ const gsd2Data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(gsd2Data);
+ const preview = buildPreview(gsd2Data, artifacts);
+
+ if (dryRun) {
+ return output({ success: true, dryRun: true, preview }, raw);
+ }
+
+ writePlanningDir(artifacts, planningRoot);
+
+ return output({
+ success: true,
+ planningDir: planningRoot,
+ filesWritten: artifacts.size,
+ milestones: gsd2Data.milestones.length,
+ preview,
+ }, raw);
+}
+
+module.exports = {
+ findGsd2Root,
+ parseGsd2,
+ buildPlanningArtifacts,
+ buildPreview,
+ writePlanningDir,
+ cmdFromGsd2,
+ // Exported for unit tests
+ parseSlicesFromRoadmap,
+ parseMilestoneTitle,
+ parseTaskTitle,
+ parseTaskDescription,
+ parseTaskMustHaves,
+ buildPlanMd,
+ buildSummaryMd,
+ buildContextMd,
+ buildRoadmapMd,
+ buildStateMd,
+ slugify,
+ zeroPad,
+};
diff --git a/tests/gsd2-import.test.cjs b/tests/gsd2-import.test.cjs
new file mode 100644
index 00000000..80becb4a
--- /dev/null
+++ b/tests/gsd2-import.test.cjs
@@ -0,0 +1,550 @@
+'use strict';
+
+const { describe, it, test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
+
+const {
+ findGsd2Root,
+ parseSlicesFromRoadmap,
+ parseMilestoneTitle,
+ parseTaskTitle,
+ parseTaskDescription,
+ parseTaskMustHaves,
+ parseGsd2,
+ buildPlanningArtifacts,
+ buildRoadmapMd,
+ buildStateMd,
+ slugify,
+ zeroPad,
+} = require('../get-shit-done/bin/lib/gsd2-import.cjs');
+
+// ─── Fixture Builders ──────────────────────────────────────────────────────
+
+/** Build a minimal but complete GSD-2 .gsd/ directory in tmpDir. */
+function makeGsd2Project(tmpDir, opts = {}) {
+ const gsdDir = path.join(tmpDir, '.gsd');
+ const m001Dir = path.join(gsdDir, 'milestones', 'M001');
+ const s01Dir = path.join(m001Dir, 'slices', 'S01');
+ const s02Dir = path.join(m001Dir, 'slices', 'S02');
+ const s01TasksDir = path.join(s01Dir, 'tasks');
+
+ fs.mkdirSync(s01TasksDir, { recursive: true });
+
+ fs.writeFileSync(path.join(gsdDir, 'PROJECT.md'), '# My Project\n\nA test project.\n');
+ fs.writeFileSync(path.join(gsdDir, 'REQUIREMENTS.md'), [
+ '# Requirements',
+ '',
+ '## Active',
+ '',
+ '### R001 — Do the thing',
+ '',
+ '- Status: active',
+ '- Description: The core requirement.',
+ '',
+ ].join('\n'));
+
+ const roadmap = [
+ '# M001: Foundation',
+ '',
+ '**Vision:** Build the foundation.',
+ '',
+ '## Success Criteria',
+ '',
+ '- It works.',
+ '',
+ '## Slices',
+ '',
+ '- [x] **S01: Setup** `risk:low` `depends:[]`',
+ ' > After this: setup complete',
+ '- [ ] **S02: Auth System** `risk:medium` `depends:[S01]`',
+ ' > After this: auth works',
+ ].join('\n');
+ fs.writeFileSync(path.join(m001Dir, 'M001-ROADMAP.md'), roadmap);
+
+ // S01 — completed slice with research and a done task
+ fs.writeFileSync(path.join(s01Dir, 'S01-PLAN.md'), [
+ '# S01: Setup',
+ '',
+ '**Goal:** Set up the project.',
+ '',
+ '## Tasks',
+ '- [x] **T01: Init**',
+ ].join('\n'));
+ fs.writeFileSync(path.join(s01Dir, 'S01-RESEARCH.md'), '# Research\n\nSome research.\n');
+ fs.writeFileSync(path.join(s01Dir, 'S01-SUMMARY.md'), '---\nstatus: done\n---\n\nSlice done.\n');
+
+ fs.writeFileSync(path.join(s01TasksDir, 'T01-PLAN.md'), [
+ '# T01: Init Project',
+ '',
+ '**Slice:** S01 — **Milestone:** M001',
+ '',
+ '## Description',
+ 'Initialize the project structure.',
+ '',
+ '## Must-Haves',
+ '- [x] package.json exists',
+ '- [x] tsconfig.json exists',
+ '',
+ '## Files',
+ '- `package.json`',
+ '- `tsconfig.json`',
+ ].join('\n'));
+ fs.writeFileSync(path.join(s01TasksDir, 'T01-SUMMARY.md'), [
+ '---',
+ 'status: done',
+ 'completed_at: 2025-01-15',
+ '---',
+ '',
+ '# T01: Init Project',
+ '',
+ 'Set up package.json and tsconfig.json.',
+ ].join('\n'));
+
+ // S02 — not started: slice appears in roadmap but no slice directory
+ if (opts.withS02Dir) {
+ fs.mkdirSync(path.join(s02Dir, 'tasks'), { recursive: true });
+ fs.writeFileSync(path.join(s02Dir, 'S02-PLAN.md'), [
+ '# S02: Auth System',
+ '',
+ '**Goal:** Add authentication.',
+ '',
+ '## Tasks',
+ '- [ ] **T01: JWT middleware**',
+ ].join('\n'));
+ fs.writeFileSync(path.join(s02Dir, 'tasks', 'T01-PLAN.md'), [
+ '# T01: JWT Middleware',
+ '',
+ '**Slice:** S02 — **Milestone:** M001',
+ '',
+ '## Description',
+ 'Implement JWT token validation middleware.',
+ '',
+ '## Must-Haves',
+ '- [ ] validateToken() returns 401 on invalid JWT',
+ ].join('\n'));
+ }
+
+ return gsdDir;
+}
+
+/** Build a two-milestone GSD-2 project. */
+function makeTwoMilestoneProject(tmpDir) {
+ const gsdDir = path.join(tmpDir, '.gsd');
+ const m001Dir = path.join(gsdDir, 'milestones', 'M001');
+ const m002Dir = path.join(gsdDir, 'milestones', 'M002');
+
+ fs.mkdirSync(path.join(m001Dir, 'slices', 'S01', 'tasks'), { recursive: true });
+ fs.mkdirSync(path.join(m002Dir, 'slices', 'S01', 'tasks'), { recursive: true });
+
+ fs.writeFileSync(path.join(gsdDir, 'PROJECT.md'), '# Multi-milestone Project\n');
+
+ fs.writeFileSync(path.join(m001Dir, 'M001-ROADMAP.md'), [
+ '# M001: Alpha',
+ '',
+ '## Slices',
+ '',
+ '- [x] **S01: Core** `risk:low` `depends:[]`',
+ '- [x] **S02: API** `risk:low` `depends:[S01]`',
+ ].join('\n'));
+
+ fs.writeFileSync(path.join(m002Dir, 'M002-ROADMAP.md'), [
+ '# M002: Beta',
+ '',
+ '## Slices',
+ '',
+ '- [ ] **S01: Dashboard** `risk:medium` `depends:[]`',
+ ].join('\n'));
+
+ return gsdDir;
+}
+
+// ─── Unit Tests ────────────────────────────────────────────────────────────
+
+describe('parseSlicesFromRoadmap', () => {
+ test('parses done and pending slices', () => {
+ const content = [
+ '## Slices',
+ '',
+ '- [x] **S01: Setup** `risk:low` `depends:[]`',
+ '- [ ] **S02: Auth System** `risk:medium` `depends:[S01]`',
+ ].join('\n');
+ const slices = parseSlicesFromRoadmap(content);
+ assert.strictEqual(slices.length, 2);
+ assert.deepStrictEqual(slices[0], { done: true, id: 'S01', title: 'Setup' });
+ assert.deepStrictEqual(slices[1], { done: false, id: 'S02', title: 'Auth System' });
+ });
+
+ test('returns empty array when no Slices section', () => {
+ const slices = parseSlicesFromRoadmap('# M001: Title\n\n## Success Criteria\n\n- Works.');
+ assert.strictEqual(slices.length, 0);
+ });
+
+ test('ignores non-slice lines in the section', () => {
+ const content = [
+ '## Slices',
+ '',
+ 'Some intro text.',
+ '- [x] **S01: Core** `risk:low` `depends:[]`',
+ ' > After this: done',
+ ].join('\n');
+ const slices = parseSlicesFromRoadmap(content);
+ assert.strictEqual(slices.length, 1);
+ assert.strictEqual(slices[0].id, 'S01');
+ });
+});
+
+describe('parseMilestoneTitle', () => {
+ test('extracts title from first heading', () => {
+ assert.strictEqual(parseMilestoneTitle('# M001: Foundation\n\nBody.'), 'Foundation');
+ });
+
+ test('returns null when heading absent', () => {
+ assert.strictEqual(parseMilestoneTitle('No heading here.'), null);
+ });
+});
+
+describe('parseTaskTitle', () => {
+ test('extracts title from task plan', () => {
+ assert.strictEqual(parseTaskTitle('# T01: Init Project\n\nBody.', 'T01'), 'Init Project');
+ });
+
+ test('falls back to provided default', () => {
+ assert.strictEqual(parseTaskTitle('No heading.', 'T01'), 'T01');
+ });
+});
+
+describe('parseTaskDescription', () => {
+ test('extracts description body', () => {
+ const content = [
+ '# T01: Title',
+ '',
+ '## Description',
+ 'Do the thing.',
+ '',
+ '## Must-Haves',
+ ].join('\n');
+ assert.strictEqual(parseTaskDescription(content), 'Do the thing.');
+ });
+
+ test('returns empty string when section absent', () => {
+ assert.strictEqual(parseTaskDescription('# T01: Title\n\nNo sections.'), '');
+ });
+});
+
+describe('parseTaskMustHaves', () => {
+ test('parses checked and unchecked items', () => {
+ const content = [
+ '## Must-Haves',
+ '- [x] File exists',
+ '- [ ] Tests pass',
+ ].join('\n');
+ const mh = parseTaskMustHaves(content);
+ assert.deepStrictEqual(mh, ['File exists', 'Tests pass']);
+ });
+
+ test('returns empty array when section absent', () => {
+ assert.deepStrictEqual(parseTaskMustHaves('# T01: Title\n\nNo sections.'), []);
+ });
+});
+
+describe('slugify', () => {
+ test('lowercases and replaces non-alphanumeric with hyphens', () => {
+ assert.strictEqual(slugify('Auth System'), 'auth-system');
+ assert.strictEqual(slugify('My Feature (v2)'), 'my-feature-v2');
+ });
+
+ test('strips leading/trailing hyphens', () => {
+ assert.strictEqual(slugify(' spaces '), 'spaces');
+ });
+});
+
+describe('zeroPad', () => {
+ test('pads to 2 digits by default', () => {
+ assert.strictEqual(zeroPad(1), '01');
+ assert.strictEqual(zeroPad(12), '12');
+ });
+});
+
+// ─── Integration Tests ─────────────────────────────────────────────────────
+
+describe('parseGsd2', () => {
+ let tmpDir;
+ beforeEach(() => { tmpDir = createTempDir('gsd2-parse-'); });
+ afterEach(() => { cleanup(tmpDir); });
+
+ test('reads project and requirements passthroughs', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ assert.ok(data.projectContent.includes('My Project'));
+ assert.ok(data.requirements.includes('R001'));
+ });
+
+ test('parses milestone with slices', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ assert.strictEqual(data.milestones.length, 1);
+ assert.strictEqual(data.milestones[0].id, 'M001');
+ assert.strictEqual(data.milestones[0].title, 'Foundation');
+ assert.strictEqual(data.milestones[0].slices.length, 2);
+ });
+
+ test('marks S01 as done, S02 as not done', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const [s01, s02] = data.milestones[0].slices;
+ assert.strictEqual(s01.done, true);
+ assert.strictEqual(s02.done, false);
+ });
+
+ test('reads research for completed slice', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ assert.ok(data.milestones[0].slices[0].research.includes('Some research'));
+ });
+
+ test('reads tasks from tasks/ directory', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const tasks = data.milestones[0].slices[0].tasks;
+ assert.strictEqual(tasks.length, 1);
+ assert.strictEqual(tasks[0].id, 'T01');
+ assert.strictEqual(tasks[0].title, 'Init Project');
+ assert.strictEqual(tasks[0].done, true);
+ });
+
+ test('parses task must-haves', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const mh = data.milestones[0].slices[0].tasks[0].mustHaves;
+ assert.deepStrictEqual(mh, ['package.json exists', 'tsconfig.json exists']);
+ });
+
+ test('handles missing .gsd/milestones/ gracefully', () => {
+ const gsdDir = path.join(tmpDir, '.gsd');
+ fs.mkdirSync(gsdDir, { recursive: true });
+ fs.writeFileSync(path.join(gsdDir, 'PROJECT.md'), '# Empty\n');
+ const data = parseGsd2(gsdDir);
+ assert.strictEqual(data.milestones.length, 0);
+ });
+
+ test('slice with no directory has empty tasks list', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ // S02 has no slice directory in the default fixture
+ const s02 = data.milestones[0].slices[1];
+ assert.strictEqual(s02.tasks.length, 0);
+ assert.strictEqual(s02.research, null);
+ });
+});
+
+describe('buildPlanningArtifacts', () => {
+ let tmpDir;
+ beforeEach(() => { tmpDir = createTempDir('gsd2-artifacts-'); });
+ afterEach(() => { cleanup(tmpDir); });
+
+ test('produces PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md, config.json', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ assert.ok(artifacts.has('PROJECT.md'));
+ assert.ok(artifacts.has('REQUIREMENTS.md'));
+ assert.ok(artifacts.has('ROADMAP.md'));
+ assert.ok(artifacts.has('STATE.md'));
+ assert.ok(artifacts.has('config.json'));
+ });
+
+ test('S01 (done) maps to phase 01 with PLAN and SUMMARY', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ assert.ok(artifacts.has('phases/01-setup/01-CONTEXT.md'));
+ assert.ok(artifacts.has('phases/01-setup/01-RESEARCH.md'));
+ assert.ok(artifacts.has('phases/01-setup/01-01-PLAN.md'));
+ assert.ok(artifacts.has('phases/01-setup/01-01-SUMMARY.md'));
+ });
+
+ test('S02 (pending) maps to phase 02 with only CONTEXT and PLAN', () => {
+ const gsdDir = makeGsd2Project(tmpDir, { withS02Dir: true });
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ assert.ok(artifacts.has('phases/02-auth-system/02-CONTEXT.md'));
+ assert.ok(artifacts.has('phases/02-auth-system/02-01-PLAN.md'));
+ assert.ok(!artifacts.has('phases/02-auth-system/02-01-SUMMARY.md'), 'no summary for pending task');
+ });
+
+ test('ROADMAP.md marks S01 done, S02 pending', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ const roadmap = artifacts.get('ROADMAP.md');
+ assert.ok(roadmap.includes('[x]'));
+ assert.ok(roadmap.includes('[ ]'));
+ });
+
+ test('PLAN.md includes frontmatter with phase and plan keys', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ const plan = artifacts.get('phases/01-setup/01-01-PLAN.md');
+ assert.ok(plan.includes('phase: "01"'));
+ assert.ok(plan.includes('plan: "01"'));
+ assert.ok(plan.includes('type: "implementation"'));
+ });
+
+ test('SUMMARY.md strips GSD-2 frontmatter and adds v1 frontmatter', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ const summary = artifacts.get('phases/01-setup/01-01-SUMMARY.md');
+ assert.ok(summary.includes('phase: "01"'));
+ assert.ok(summary.includes('plan: "01"'));
+ // GSD-2 frontmatter field should not appear
+ assert.ok(!summary.includes('completed_at:'));
+ // Body content should be preserved
+ assert.ok(summary.includes('Init Project'));
+ });
+
+ test('config.json is valid JSON', () => {
+ const gsdDir = makeGsd2Project(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ assert.doesNotThrow(() => JSON.parse(artifacts.get('config.json')));
+ });
+
+ test('multi-milestone: slices numbered sequentially across milestones', () => {
+ const gsdDir = makeTwoMilestoneProject(tmpDir);
+ const data = parseGsd2(gsdDir);
+ const artifacts = buildPlanningArtifacts(data);
+ // M001/S01 → phase 01, M001/S02 → phase 02, M002/S01 → phase 03
+ assert.ok(artifacts.has('phases/01-core/01-CONTEXT.md'));
+ assert.ok(artifacts.has('phases/02-api/02-CONTEXT.md'));
+ assert.ok(artifacts.has('phases/03-dashboard/03-CONTEXT.md'));
+ });
+});
+
+describe('buildRoadmapMd', () => {
+ test('produces milestone sections with checked/unchecked phases', () => {
+ const milestones = [{ id: 'M001', title: 'Alpha', slices: [] }];
+ const phaseMap = [
+ { milestoneId: 'M001', milestoneTitle: 'Alpha', slice: { done: true, title: 'Core' }, phaseNum: 1 },
+ { milestoneId: 'M001', milestoneTitle: 'Alpha', slice: { done: false, title: 'API' }, phaseNum: 2 },
+ ];
+ const roadmap = buildRoadmapMd(milestones, phaseMap);
+ assert.ok(roadmap.includes('## M001: Alpha'));
+ assert.ok(roadmap.includes('[x]'));
+ assert.ok(roadmap.includes('[ ]'));
+ assert.ok(roadmap.includes('Phase 01: core'));
+ assert.ok(roadmap.includes('Phase 02: api'));
+ });
+});
+
+describe('buildStateMd', () => {
+ test('sets current phase to first incomplete slice', () => {
+ const phaseMap = [
+ { milestoneId: 'M001', milestoneTitle: 'Alpha', slice: { done: true, title: 'Core' }, phaseNum: 1 },
+ { milestoneId: 'M001', milestoneTitle: 'Alpha', slice: { done: false, title: 'API Layer' }, phaseNum: 2 },
+ ];
+ const state = buildStateMd(phaseMap);
+ assert.ok(state.includes('Phase: 02'));
+ assert.ok(state.includes('api-layer'));
+ assert.ok(state.includes('Ready to plan'));
+ });
+
+ test('reports all complete when all slices done', () => {
+ const phaseMap = [
+ { milestoneId: 'M001', milestoneTitle: 'Alpha', slice: { done: true, title: 'Core' }, phaseNum: 1 },
+ ];
+ const state = buildStateMd(phaseMap);
+ assert.ok(state.includes('All phases complete'));
+ });
+});
+
+// ─── CLI Integration Tests ──────────────────────────────────────────────────
+
+describe('gsd-tools from-gsd2 CLI', () => {
+ let tmpDir;
+ beforeEach(() => { tmpDir = createTempDir('gsd2-cli-'); });
+ afterEach(() => { cleanup(tmpDir); });
+
+ test('--dry-run returns preview without writing files', () => {
+ makeGsd2Project(tmpDir);
+ const result = runGsdTools(['from-gsd2', '--dry-run', '--raw'], tmpDir);
+ assert.ok(result.success, result.error);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.dryRun, true);
+ assert.ok(parsed.preview.includes('PROJECT.md'));
+ assert.ok(!fs.existsSync(path.join(tmpDir, '.planning')), 'no files written in dry-run');
+ });
+
+ test('writes .planning/ directory with correct structure', () => {
+ makeGsd2Project(tmpDir);
+ const result = runGsdTools(['from-gsd2', '--raw'], tmpDir);
+ assert.ok(result.success, result.error);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.success, true);
+ assert.ok(parsed.filesWritten > 0);
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md')));
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')));
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md')));
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-PLAN.md')));
+ });
+
+ test('errors when no .gsd/ directory present', () => {
+ const result = runGsdTools(['from-gsd2', '--raw'], tmpDir);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.success, false);
+ assert.ok(parsed.error.includes('No .gsd/'));
+ });
+
+ test('errors when .planning/ already exists without --force', () => {
+ makeGsd2Project(tmpDir);
+ fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
+ const result = runGsdTools(['from-gsd2', '--raw'], tmpDir);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.success, false);
+ assert.ok(parsed.error.includes('already exists'));
+ });
+
+ test('--force overwrites existing .planning/', () => {
+ makeGsd2Project(tmpDir);
+ fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
+ fs.writeFileSync(path.join(tmpDir, '.planning', 'OLD.md'), 'old content');
+ const result = runGsdTools(['from-gsd2', '--force', '--raw'], tmpDir);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.success, true);
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md')));
+ });
+
+ test('--path resolves target directory', () => {
+ const projectDir = path.join(tmpDir, 'myproject');
+ fs.mkdirSync(projectDir, { recursive: true });
+ makeGsd2Project(projectDir);
+ // Run from tmpDir but point at projectDir
+ const result = runGsdTools(['from-gsd2', '--path', projectDir, '--dry-run', '--raw'], tmpDir);
+ assert.ok(result.success, result.error);
+ const parsed = JSON.parse(result.output);
+ assert.strictEqual(parsed.dryRun, true);
+ assert.ok(parsed.preview.includes('PROJECT.md'));
+ });
+
+ test('completion state: S01 done → [x] in ROADMAP.md', () => {
+ makeGsd2Project(tmpDir);
+ runGsdTools(['from-gsd2', '--raw'], tmpDir);
+ const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf8');
+ assert.ok(roadmap.includes('[x]'));
+ // S02 is pending
+ assert.ok(roadmap.includes('[ ]'));
+ });
+
+ test('SUMMARY.md written for completed task, not for pending', () => {
+ makeGsd2Project(tmpDir, { withS02Dir: true });
+ runGsdTools(['from-gsd2', '--raw'], tmpDir);
+ // S01/T01 is done → SUMMARY exists
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-SUMMARY.md')));
+ // S02/T01 is pending → no SUMMARY
+ assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-auth-system', '02-01-SUMMARY.md')));
+ });
+});