From 96eef85c407e912cac87ab770a2afcbd7b359a29 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Apr 2026 21:30:13 -0400 Subject: [PATCH] feat(import): add /gsd-from-gsd2 reverse migration from GSD-2 to v1 (#2072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new command and CLI subcommand that converts a GSD-2 `.gsd/` project back to GSD v1 `.planning/` format — the reverse of the forward migration GSD-2 ships. Closes #2069 Maps GSD-2's Milestone → Slice → Task hierarchy to v1's flat Milestone sections → Phase → Plan structure. Slices are numbered sequentially across all milestones; tasks become numbered plans within their phase. Completion state, research files, and summaries are preserved. New files: - `get-shit-done/bin/lib/gsd2-import.cjs` — parser, transformer, writer - `commands/gsd/from-gsd2.md` — slash command definition - `tests/gsd2-import.test.cjs` — 41 tests, 99.21% statement coverage Co-authored-by: Claude Sonnet 4.6 --- commands/gsd/from-gsd2.md | 45 +++ get-shit-done/bin/gsd-tools.cjs | 12 + get-shit-done/bin/lib/gsd2-import.cjs | 511 ++++++++++++++++++++++++ tests/gsd2-import.test.cjs | 550 ++++++++++++++++++++++++++ 4 files changed, 1118 insertions(+) create mode 100644 commands/gsd/from-gsd2.md create mode 100644 get-shit-done/bin/lib/gsd2-import.cjs create mode 100644 tests/gsd2-import.test.cjs 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'))); + }); +});