diff --git a/get-shit-done/bin/gsd-tools.js b/get-shit-done/bin/gsd-tools.js
index 3ec18f9e4..f850b20d8 100755
--- a/get-shit-done/bin/gsd-tools.js
+++ b/get-shit-done/bin/gsd-tools.js
@@ -1038,6 +1038,270 @@ function cmdTemplateSelect(cwd, planPath, raw) {
}
}
+function cmdPhasePlanIndex(cwd, phase, raw) {
+ if (!phase) {
+ error('phase required for phase-plan-index');
+ }
+
+ const phasesDir = path.join(cwd, '.planning', 'phases');
+ const normalized = normalizePhaseName(phase);
+
+ // Find phase directory
+ let phaseDir = null;
+ let phaseDirName = null;
+ try {
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
+ const match = dirs.find(d => d.startsWith(normalized));
+ if (match) {
+ phaseDir = path.join(phasesDir, match);
+ phaseDirName = match;
+ }
+ } catch {
+ // phases dir doesn't exist
+ }
+
+ if (!phaseDir) {
+ output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
+ return;
+ }
+
+ // Get all files in phase directory
+ const phaseFiles = fs.readdirSync(phaseDir);
+ const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
+ const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
+
+ // Build set of plan IDs with summaries
+ const completedPlanIds = new Set(
+ summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
+ );
+
+ const plans = [];
+ const waves = {};
+ const incomplete = [];
+ let hasCheckpoints = false;
+
+ for (const planFile of planFiles) {
+ const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
+ const planPath = path.join(phaseDir, planFile);
+ const content = fs.readFileSync(planPath, 'utf-8');
+ const fm = extractFrontmatter(content);
+
+ // Count tasks (## Task N patterns)
+ const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
+ const taskCount = taskMatches.length;
+
+ // Parse wave as integer
+ const wave = parseInt(fm.wave, 10) || 1;
+
+ // Parse autonomous (default true if not specified)
+ let autonomous = true;
+ if (fm.autonomous !== undefined) {
+ autonomous = fm.autonomous === 'true' || fm.autonomous === true;
+ }
+
+ if (!autonomous) {
+ hasCheckpoints = true;
+ }
+
+ // Parse files-modified
+ let filesModified = [];
+ if (fm['files-modified']) {
+ filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']];
+ }
+
+ const hasSummary = completedPlanIds.has(planId);
+ if (!hasSummary) {
+ incomplete.push(planId);
+ }
+
+ const plan = {
+ id: planId,
+ wave,
+ autonomous,
+ objective: fm.objective || null,
+ files_modified: filesModified,
+ task_count: taskCount,
+ has_summary: hasSummary,
+ };
+
+ plans.push(plan);
+
+ // Group by wave
+ const waveKey = String(wave);
+ if (!waves[waveKey]) {
+ waves[waveKey] = [];
+ }
+ waves[waveKey].push(planId);
+ }
+
+ const result = {
+ phase: normalized,
+ plans,
+ waves,
+ incomplete,
+ has_checkpoints: hasCheckpoints,
+ };
+
+ output(result, raw);
+}
+
+function cmdStateSnapshot(cwd, raw) {
+ const statePath = path.join(cwd, '.planning', 'STATE.md');
+
+ if (!fs.existsSync(statePath)) {
+ output({ error: 'STATE.md not found' }, raw);
+ return;
+ }
+
+ const content = fs.readFileSync(statePath, 'utf-8');
+
+ // Helper to extract **Field:** value patterns
+ const extractField = (fieldName) => {
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
+ const match = content.match(pattern);
+ return match ? match[1].trim() : null;
+ };
+
+ // Extract basic fields
+ const currentPhase = extractField('Current Phase');
+ const currentPhaseName = extractField('Current Phase Name');
+ const totalPhasesRaw = extractField('Total Phases');
+ const currentPlan = extractField('Current Plan');
+ const totalPlansRaw = extractField('Total Plans in Phase');
+ const status = extractField('Status');
+ const progressRaw = extractField('Progress');
+ const lastActivity = extractField('Last Activity');
+ const lastActivityDesc = extractField('Last Activity Description');
+ const pausedAt = extractField('Paused At');
+
+ // Parse numeric fields
+ const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
+ const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
+
+ // Extract decisions table
+ const decisions = [];
+ const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
+ if (decisionsMatch) {
+ const tableBody = decisionsMatch[1];
+ const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
+ for (const row of rows) {
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
+ if (cells.length >= 3) {
+ decisions.push({
+ phase: cells[0],
+ summary: cells[1],
+ rationale: cells[2],
+ });
+ }
+ }
+ }
+
+ // Extract blockers list
+ const blockers = [];
+ const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
+ if (blockersMatch) {
+ const blockersSection = blockersMatch[1];
+ const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
+ for (const item of items) {
+ blockers.push(item.replace(/^-\s+/, '').trim());
+ }
+ }
+
+ // Extract session info
+ const session = {
+ last_date: null,
+ stopped_at: null,
+ resume_file: null,
+ };
+
+ const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
+ if (sessionMatch) {
+ const sessionSection = sessionMatch[1];
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
+
+ if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
+ if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
+ if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
+ }
+
+ const result = {
+ current_phase: currentPhase,
+ current_phase_name: currentPhaseName,
+ total_phases: totalPhases,
+ current_plan: currentPlan,
+ total_plans_in_phase: totalPlansInPhase,
+ status,
+ progress_percent: progressPercent,
+ last_activity: lastActivity,
+ last_activity_desc: lastActivityDesc,
+ decisions,
+ blockers,
+ paused_at: pausedAt,
+ session,
+ };
+
+ output(result, raw);
+}
+
+function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
+ if (!summaryPath) {
+ error('summary-path required for summary-extract');
+ }
+
+ const fullPath = path.join(cwd, summaryPath);
+
+ if (!fs.existsSync(fullPath)) {
+ output({ error: 'File not found', path: summaryPath }, raw);
+ return;
+ }
+
+ const content = fs.readFileSync(fullPath, 'utf-8');
+ const fm = extractFrontmatter(content);
+
+ // Parse key-decisions into structured format
+ const parseDecisions = (decisionsList) => {
+ if (!decisionsList || !Array.isArray(decisionsList)) return [];
+ return decisionsList.map(d => {
+ const colonIdx = d.indexOf(':');
+ if (colonIdx > 0) {
+ return {
+ summary: d.substring(0, colonIdx).trim(),
+ rationale: d.substring(colonIdx + 1).trim(),
+ };
+ }
+ return { summary: d, rationale: null };
+ });
+ };
+
+ // Build full result
+ const fullResult = {
+ path: summaryPath,
+ one_liner: fm['one-liner'] || null,
+ key_files: fm['key-files'] || [],
+ tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
+ patterns: fm['patterns-established'] || [],
+ decisions: parseDecisions(fm['key-decisions']),
+ };
+
+ // If fields specified, filter to only those fields
+ if (fields && fields.length > 0) {
+ const filtered = { path: summaryPath };
+ for (const field of fields) {
+ if (fullResult[field] !== undefined) {
+ filtered[field] = fullResult[field];
+ }
+ }
+ output(filtered, raw);
+ return;
+ }
+
+ output(fullResult, raw);
+}
+
// ─── Compound Commands ────────────────────────────────────────────────────────
function resolveModelInternal(cwd, agentType) {
@@ -1870,6 +2134,24 @@ function main() {
break;
}
+ case 'phase-plan-index': {
+ cmdPhasePlanIndex(cwd, args[1], raw);
+ break;
+ }
+
+ case 'state-snapshot': {
+ cmdStateSnapshot(cwd, raw);
+ break;
+ }
+
+ case 'summary-extract': {
+ const summaryPath = args[1];
+ const fieldsIndex = args.indexOf('--fields');
+ const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
+ cmdSummaryExtract(cwd, summaryPath, fields, raw);
+ break;
+ }
+
default:
error(`Unknown command: ${command}`);
}
diff --git a/get-shit-done/bin/gsd-tools.test.js b/get-shit-done/bin/gsd-tools.test.js
index 6fb963077..cddb80fea 100644
--- a/get-shit-done/bin/gsd-tools.test.js
+++ b/get-shit-done/bin/gsd-tools.test.js
@@ -597,3 +597,458 @@ describe('phase next-decimal command', () => {
assert.strictEqual(output.next, '06.1', 'should still suggest 06.1');
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// phase-plan-index command
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('phase-plan-index command', () => {
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = createTempProject();
+ });
+
+ afterEach(() => {
+ cleanup(tmpDir);
+ });
+
+ test('empty phase directory returns empty plans array', () => {
+ fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
+
+ const result = runGsdTools('phase-plan-index 03', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.phase, '03', 'phase number correct');
+ assert.deepStrictEqual(output.plans, [], 'plans should be empty');
+ assert.deepStrictEqual(output.waves, {}, 'waves should be empty');
+ assert.deepStrictEqual(output.incomplete, [], 'incomplete should be empty');
+ assert.strictEqual(output.has_checkpoints, false, 'no checkpoints');
+ });
+
+ test('extracts single plan with frontmatter', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '03-01-PLAN.md'),
+ `---
+wave: 1
+autonomous: true
+objective: Set up database schema
+files-modified: [prisma/schema.prisma, src/lib/db.ts]
+---
+
+## Task 1: Create schema
+## Task 2: Generate client
+`
+ );
+
+ const result = runGsdTools('phase-plan-index 03', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.plans.length, 1, 'should have 1 plan');
+ assert.strictEqual(output.plans[0].id, '03-01', 'plan id correct');
+ assert.strictEqual(output.plans[0].wave, 1, 'wave extracted');
+ assert.strictEqual(output.plans[0].autonomous, true, 'autonomous extracted');
+ assert.strictEqual(output.plans[0].objective, 'Set up database schema', 'objective extracted');
+ assert.deepStrictEqual(output.plans[0].files_modified, ['prisma/schema.prisma', 'src/lib/db.ts'], 'files extracted');
+ assert.strictEqual(output.plans[0].task_count, 2, 'task count correct');
+ assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet');
+ });
+
+ test('groups multiple plans by wave', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '03-01-PLAN.md'),
+ `---
+wave: 1
+autonomous: true
+objective: Database setup
+---
+
+## Task 1: Schema
+`
+ );
+
+ fs.writeFileSync(
+ path.join(phaseDir, '03-02-PLAN.md'),
+ `---
+wave: 1
+autonomous: true
+objective: Auth setup
+---
+
+## Task 1: JWT
+`
+ );
+
+ fs.writeFileSync(
+ path.join(phaseDir, '03-03-PLAN.md'),
+ `---
+wave: 2
+autonomous: false
+objective: API routes
+---
+
+## Task 1: Routes
+`
+ );
+
+ const result = runGsdTools('phase-plan-index 03', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.plans.length, 3, 'should have 3 plans');
+ assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans');
+ assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan');
+ });
+
+ test('detects incomplete plans (no matching summary)', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ // Plan with summary
+ fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
+ fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
+
+ // Plan without summary
+ fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), `---\nwave: 2\n---\n## Task 1`);
+
+ const result = runGsdTools('phase-plan-index 03', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.plans[0].has_summary, true, 'first plan has summary');
+ assert.strictEqual(output.plans[1].has_summary, false, 'second plan has no summary');
+ assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct');
+ });
+
+ test('detects checkpoints (autonomous: false)', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '03-01-PLAN.md'),
+ `---
+wave: 1
+autonomous: false
+objective: Manual review needed
+---
+
+## Task 1: Review
+`
+ );
+
+ const result = runGsdTools('phase-plan-index 03', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.has_checkpoints, true, 'should detect checkpoint');
+ assert.strictEqual(output.plans[0].autonomous, false, 'plan marked non-autonomous');
+ });
+
+ test('phase not found returns error', () => {
+ const result = runGsdTools('phase-plan-index 99', tmpDir);
+ assert.ok(result.success, `Command should succeed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.error, 'Phase not found', 'should report phase not found');
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// state-snapshot command
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('state-snapshot command', () => {
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = createTempProject();
+ });
+
+ afterEach(() => {
+ cleanup(tmpDir);
+ });
+
+ test('missing STATE.md returns error', () => {
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command should succeed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.error, 'STATE.md not found', 'should report missing file');
+ });
+
+ test('extracts basic fields from STATE.md', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '.planning', 'STATE.md'),
+ `# Project State
+
+**Current Phase:** 03
+**Current Phase Name:** API Layer
+**Total Phases:** 6
+**Current Plan:** 03-02
+**Total Plans in Phase:** 3
+**Status:** In progress
+**Progress:** 45%
+**Last Activity:** 2024-01-15
+**Last Activity Description:** Completed 03-01-PLAN.md
+`
+ );
+
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.current_phase, '03', 'current phase extracted');
+ assert.strictEqual(output.current_phase_name, 'API Layer', 'phase name extracted');
+ assert.strictEqual(output.total_phases, 6, 'total phases extracted');
+ assert.strictEqual(output.current_plan, '03-02', 'current plan extracted');
+ assert.strictEqual(output.total_plans_in_phase, 3, 'total plans extracted');
+ assert.strictEqual(output.status, 'In progress', 'status extracted');
+ assert.strictEqual(output.progress_percent, 45, 'progress extracted');
+ assert.strictEqual(output.last_activity, '2024-01-15', 'last activity date extracted');
+ });
+
+ test('extracts decisions table', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '.planning', 'STATE.md'),
+ `# Project State
+
+**Current Phase:** 01
+
+## Decisions Made
+
+| Phase | Decision | Rationale |
+|-------|----------|-----------|
+| 01 | Use Prisma | Better DX than raw SQL |
+| 02 | JWT auth | Stateless authentication |
+`
+ );
+
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.decisions.length, 2, 'should have 2 decisions');
+ assert.strictEqual(output.decisions[0].phase, '01', 'first decision phase');
+ assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'first decision summary');
+ assert.strictEqual(output.decisions[0].rationale, 'Better DX than raw SQL', 'first decision rationale');
+ });
+
+ test('extracts blockers list', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '.planning', 'STATE.md'),
+ `# Project State
+
+**Current Phase:** 03
+
+## Blockers
+
+- Waiting for API credentials
+- Need design review for dashboard
+`
+ );
+
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.deepStrictEqual(output.blockers, [
+ 'Waiting for API credentials',
+ 'Need design review for dashboard',
+ ], 'blockers extracted');
+ });
+
+ test('extracts session continuity info', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '.planning', 'STATE.md'),
+ `# Project State
+
+**Current Phase:** 03
+
+## Session
+
+**Last Date:** 2024-01-15
+**Stopped At:** Phase 3, Plan 2, Task 1
+**Resume File:** .planning/phases/03-api/03-02-PLAN.md
+`
+ );
+
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.session.last_date, '2024-01-15', 'session date extracted');
+ assert.strictEqual(output.session.stopped_at, 'Phase 3, Plan 2, Task 1', 'stopped at extracted');
+ assert.strictEqual(output.session.resume_file, '.planning/phases/03-api/03-02-PLAN.md', 'resume file extracted');
+ });
+
+ test('handles paused_at field', () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '.planning', 'STATE.md'),
+ `# Project State
+
+**Current Phase:** 03
+**Paused At:** Phase 3, Plan 1, Task 2 - mid-implementation
+`
+ );
+
+ const result = runGsdTools('state-snapshot', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.paused_at, 'Phase 3, Plan 1, Task 2 - mid-implementation', 'paused_at extracted');
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// summary-extract command
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('summary-extract command', () => {
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = createTempProject();
+ });
+
+ afterEach(() => {
+ cleanup(tmpDir);
+ });
+
+ test('missing file returns error', () => {
+ const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
+ assert.ok(result.success, `Command should succeed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.error, 'File not found', 'should report missing file');
+ });
+
+ test('extracts all fields from SUMMARY.md', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '01-01-SUMMARY.md'),
+ `---
+one-liner: Set up Prisma with User and Project models
+key-files:
+ - prisma/schema.prisma
+ - src/lib/db.ts
+tech-stack:
+ added:
+ - prisma
+ - zod
+patterns-established:
+ - Repository pattern
+ - Dependency injection
+key-decisions:
+ - Use Prisma over Drizzle: Better DX and ecosystem
+ - Single database: Start simple, shard later
+---
+
+# Summary
+
+Full summary content here.
+`
+ );
+
+ const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct');
+ assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted');
+ assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted');
+ assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted');
+ assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted');
+ assert.strictEqual(output.decisions.length, 2, 'decisions extracted');
+ });
+
+ test('selective extraction with --fields', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '01-01-SUMMARY.md'),
+ `---
+one-liner: Set up database
+key-files:
+ - prisma/schema.prisma
+tech-stack:
+ added:
+ - prisma
+patterns-established:
+ - Repository pattern
+key-decisions:
+ - Use Prisma: Better DX
+---
+`
+ );
+
+ const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included');
+ assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included');
+ assert.strictEqual(output.tech_added, undefined, 'tech_added excluded');
+ assert.strictEqual(output.patterns, undefined, 'patterns excluded');
+ assert.strictEqual(output.decisions, undefined, 'decisions excluded');
+ });
+
+ test('handles missing frontmatter fields gracefully', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '01-01-SUMMARY.md'),
+ `---
+one-liner: Minimal summary
+---
+
+# Summary
+`
+ );
+
+ const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted');
+ assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty');
+ assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty');
+ assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty');
+ assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty');
+ });
+
+ test('parses key-decisions with rationale', () => {
+ const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
+ fs.mkdirSync(phaseDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(phaseDir, '01-01-SUMMARY.md'),
+ `---
+key-decisions:
+ - Use Prisma: Better DX than alternatives
+ - JWT tokens: Stateless auth for scalability
+---
+`
+ );
+
+ const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
+ assert.ok(result.success, `Command failed: ${result.error}`);
+
+ const output = JSON.parse(result.output);
+ assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed');
+ assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed');
+ assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary');
+ assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale');
+ });
+});
diff --git a/get-shit-done/workflows/complete-milestone.md b/get-shit-done/workflows/complete-milestone.md
index 564667510..e7f4c47ed 100644
--- a/get-shit-done/workflows/complete-milestone.md
+++ b/get-shit-done/workflows/complete-milestone.md
@@ -128,12 +128,13 @@ Milestone Stats:
-Read all phase SUMMARY.md files in milestone range:
+Extract one-liners from SUMMARY.md files using summary-extract:
```bash
-cat .planning/phases/01-*/01-*-SUMMARY.md
-cat .planning/phases/02-*/02-*-SUMMARY.md
-# ... for each phase in milestone
+# For each phase in milestone, extract one-liner
+for summary in .planning/phases/*-*/*-SUMMARY.md; do
+ node ~/.claude/get-shit-done/bin/gsd-tools.js summary-extract "$summary" --fields one_liner | jq -r '.one_liner'
+done
```
Extract 4-6 key accomplishments. Present:
diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md
index 183d79eab..d20ad404d 100644
--- a/get-shit-done/workflows/execute-phase.md
+++ b/get-shit-done/workflows/execute-phase.md
@@ -47,31 +47,16 @@ From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.
Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)"
-
+
+Load plan inventory with wave grouping in one call:
```bash
-ls -1 "$PHASE_DIR"/*-PLAN.md 2>/dev/null | sort
-ls -1 "$PHASE_DIR"/*-SUMMARY.md 2>/dev/null | sort
+PLAN_INDEX=$(node ~/.claude/get-shit-done/bin/gsd-tools.js phase-plan-index "${PHASE_NUMBER}")
```
-For each plan, read frontmatter: `wave`, `autonomous`, `gap_closure`.
+Parse JSON for: `phase`, `plans[]` (each with `id`, `wave`, `autonomous`, `objective`, `files_modified`, `task_count`, `has_summary`), `waves` (map of wave number → plan IDs), `incomplete`, `has_checkpoints`.
-Build inventory: path, plan ID, wave, autonomous flag, gap_closure flag, completion (SUMMARY exists = complete).
-
-**Filtering:** Skip completed plans. If `--gaps-only`: also skip non-gap_closure plans. If all filtered: "No matching incomplete plans" → exit.
-
-
-
-
-```bash
-for plan in $PHASE_DIR/*-PLAN.md; do
- wave=$(grep "^wave:" "$plan" | cut -d: -f2 | tr -d ' ')
- autonomous=$(grep "^autonomous:" "$plan" | cut -d: -f2 | tr -d ' ')
- echo "$plan:$wave:$autonomous"
-done
-```
-
-Group by wave number. **No dependency analysis needed** — waves pre-computed during `/gsd:plan-phase`.
+**Filtering:** Skip plans where `has_summary: true`. If `--gaps-only`: also skip non-gap_closure plans. If all filtered: "No matching incomplete plans" → exit.
Report:
```
diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md
index ec6a954af..1b1d47c2a 100644
--- a/get-shit-done/workflows/plan-phase.md
+++ b/get-shit-done/workflows/plan-phase.md
@@ -75,7 +75,8 @@ Display banner:
```bash
PHASE_DESC=$(node ~/.claude/get-shit-done/bin/gsd-tools.js roadmap get-phase "${PHASE}" | jq -r '.section')
REQUIREMENTS=$(cat .planning/REQUIREMENTS.md 2>/dev/null | grep -A100 "## Requirements" | head -50)
-DECISIONS=$(grep -A20 "### Decisions Made" .planning/STATE.md 2>/dev/null)
+STATE_SNAP=$(node ~/.claude/get-shit-done/bin/gsd-tools.js state-snapshot)
+# Extract decisions from state-snapshot JSON: jq '.decisions[] | "\(.phase): \(.summary) - \(.rationale)"'
```
Research prompt:
diff --git a/get-shit-done/workflows/research-phase.md b/get-shit-done/workflows/research-phase.md
index bd6fb63de..003f26c32 100644
--- a/get-shit-done/workflows/research-phase.md
+++ b/get-shit-done/workflows/research-phase.md
@@ -38,7 +38,8 @@ If exists: Offer update/view/skip options.
echo "$PHASE_INFO" | jq -r '.section'
cat .planning/REQUIREMENTS.md 2>/dev/null
cat .planning/phases/${PHASE}-*/*-CONTEXT.md 2>/dev/null
-grep -A30 "### Decisions Made" .planning/STATE.md 2>/dev/null
+# Decisions from state-snapshot (structured JSON)
+node ~/.claude/get-shit-done/bin/gsd-tools.js state-snapshot | jq '.decisions'
```
## Step 4: Spawn Researcher