feat: add context-optimizing parsing commands to gsd-tools (#473)
Add three new commands that extract structured data from GSD files, reducing context usage by returning only parsed fields instead of raw file content: - phase-plan-index: Plans grouped by wave with metadata (id, wave, autonomous, objective, files_modified, task_count, has_summary) - state-snapshot: Parsed STATE.md fields (current phase, decisions, blockers, session info) - summary-extract: Frontmatter extraction with optional --fields filter Update workflows to use new commands: - execute-phase: Use phase-plan-index instead of ls + grep loop - plan-phase: Use state-snapshot for decisions - research-phase: Use state-snapshot for decisions - complete-milestone: Use summary-extract for one-liners Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,12 +128,13 @@ Milestone Stats:
|
||||
|
||||
<step name="extract_accomplishments">
|
||||
|
||||
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:
|
||||
|
||||
@@ -47,31 +47,16 @@ From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.
|
||||
Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)"
|
||||
</step>
|
||||
|
||||
<step name="discover_plans">
|
||||
<step name="discover_and_group_plans">
|
||||
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.
|
||||
</step>
|
||||
|
||||
<step name="group_by_wave">
|
||||
|
||||
```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:
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user