Break the 5324-line monolith into focused modules: - core.cjs: shared utilities, constants, internal helpers - frontmatter.cjs: YAML frontmatter parsing/serialization/CRUD - state.cjs: STATE.md operations + progression engine - phase.cjs: phase CRUD, query, and lifecycle - roadmap.cjs: roadmap parsing and updates - verify.cjs: verification suite + consistency/health validation - config.cjs: config ensure/set/get - template.cjs: template selection and fill - milestone.cjs: milestone + requirements lifecycle - commands.cjs: standalone utility commands - init.cjs: compound init commands for workflow bootstrapping gsd-tools.cjs is now a thin CLI router (~550 lines including JSDoc) that imports from lib/ modules. All 81 tests pass. Also updates package.json test script to point to tests/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
/**
|
|
* State — STATE.md operations and progression engine
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { loadConfig, output, error } = require('./core.cjs');
|
|
|
|
function cmdStateLoad(cwd, raw) {
|
|
const config = loadConfig(cwd);
|
|
const planningDir = path.join(cwd, '.planning');
|
|
|
|
let stateRaw = '';
|
|
try {
|
|
stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8');
|
|
} catch {}
|
|
|
|
const configExists = fs.existsSync(path.join(planningDir, 'config.json'));
|
|
const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
|
|
const stateExists = stateRaw.length > 0;
|
|
|
|
const result = {
|
|
config,
|
|
state_raw: stateRaw,
|
|
state_exists: stateExists,
|
|
roadmap_exists: roadmapExists,
|
|
config_exists: configExists,
|
|
};
|
|
|
|
// For --raw, output a condensed key=value format
|
|
if (raw) {
|
|
const c = config;
|
|
const lines = [
|
|
`model_profile=${c.model_profile}`,
|
|
`commit_docs=${c.commit_docs}`,
|
|
`branching_strategy=${c.branching_strategy}`,
|
|
`phase_branch_template=${c.phase_branch_template}`,
|
|
`milestone_branch_template=${c.milestone_branch_template}`,
|
|
`parallelization=${c.parallelization}`,
|
|
`research=${c.research}`,
|
|
`plan_checker=${c.plan_checker}`,
|
|
`verifier=${c.verifier}`,
|
|
`config_exists=${configExists}`,
|
|
`roadmap_exists=${roadmapExists}`,
|
|
`state_exists=${stateExists}`,
|
|
];
|
|
process.stdout.write(lines.join('\n'));
|
|
process.exit(0);
|
|
}
|
|
|
|
output(result);
|
|
}
|
|
|
|
function cmdStateGet(cwd, section, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
try {
|
|
const content = fs.readFileSync(statePath, 'utf-8');
|
|
|
|
if (!section) {
|
|
output({ content }, raw, content);
|
|
return;
|
|
}
|
|
|
|
// Try to find markdown section or field
|
|
const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
// Check for **field:** value
|
|
const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
|
const fieldMatch = content.match(fieldPattern);
|
|
if (fieldMatch) {
|
|
output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
|
|
return;
|
|
}
|
|
|
|
// Check for ## Section
|
|
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
|
|
const sectionMatch = content.match(sectionPattern);
|
|
if (sectionMatch) {
|
|
output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
|
|
return;
|
|
}
|
|
|
|
output({ error: `Section or field "${section}" not found` }, raw, '');
|
|
} catch {
|
|
error('STATE.md not found');
|
|
}
|
|
}
|
|
|
|
function cmdStatePatch(cwd, patches, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
try {
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const results = { updated: [], failed: [] };
|
|
|
|
for (const [field, value] of Object.entries(patches)) {
|
|
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
|
|
|
if (pattern.test(content)) {
|
|
content = content.replace(pattern, `$1${value}`);
|
|
results.updated.push(field);
|
|
} else {
|
|
results.failed.push(field);
|
|
}
|
|
}
|
|
|
|
if (results.updated.length > 0) {
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
}
|
|
|
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
} catch {
|
|
error('STATE.md not found');
|
|
}
|
|
}
|
|
|
|
function cmdStateUpdate(cwd, field, value) {
|
|
if (!field || value === undefined) {
|
|
error('field and value required for state update');
|
|
}
|
|
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
try {
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
|
if (pattern.test(content)) {
|
|
content = content.replace(pattern, `$1${value}`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ updated: true });
|
|
} else {
|
|
output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
|
|
}
|
|
} catch {
|
|
output({ updated: false, reason: 'STATE.md not found' });
|
|
}
|
|
}
|
|
|
|
// ─── State Progression Engine ────────────────────────────────────────────────
|
|
|
|
function stateExtractField(content, fieldName) {
|
|
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
|
|
const match = content.match(pattern);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
function stateReplaceField(content, fieldName, newValue) {
|
|
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
|
if (pattern.test(content)) {
|
|
return content.replace(pattern, `$1${newValue}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cmdStateAdvancePlan(cwd, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10);
|
|
const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10);
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
if (isNaN(currentPlan) || isNaN(totalPlans)) {
|
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
|
|
return;
|
|
}
|
|
|
|
if (currentPlan >= totalPlans) {
|
|
content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
|
|
content = stateReplaceField(content, 'Last Activity', today) || content;
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
|
|
} else {
|
|
const newPlan = currentPlan + 1;
|
|
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
|
|
content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
|
|
content = stateReplaceField(content, 'Last Activity', today) || content;
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
|
|
}
|
|
}
|
|
|
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const { phase, plan, duration, tasks, files } = options;
|
|
|
|
if (!phase || !plan || !duration) {
|
|
output({ error: 'phase, plan, and duration required' }, raw);
|
|
return;
|
|
}
|
|
|
|
// Find Performance Metrics section and its table
|
|
const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
|
|
const metricsMatch = content.match(metricsPattern);
|
|
|
|
if (metricsMatch) {
|
|
const tableHeader = metricsMatch[1];
|
|
let tableBody = metricsMatch[2].trimEnd();
|
|
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
|
|
|
|
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
|
|
tableBody = newRow;
|
|
} else {
|
|
tableBody = tableBody + '\n' + newRow;
|
|
}
|
|
|
|
content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ recorded: true, phase, plan, duration }, raw, 'true');
|
|
} else {
|
|
output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
function cmdStateUpdateProgress(cwd, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
|
|
// Count summaries across all phases
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
let totalPlans = 0;
|
|
let totalSummaries = 0;
|
|
|
|
if (fs.existsSync(phasesDir)) {
|
|
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory()).map(e => e.name);
|
|
for (const dir of phaseDirs) {
|
|
const files = fs.readdirSync(path.join(phasesDir, dir));
|
|
totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
|
|
totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
|
|
}
|
|
}
|
|
|
|
const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0;
|
|
const barWidth = 10;
|
|
const filled = Math.round(percent / 100 * barWidth);
|
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
const progressStr = `[${bar}] ${percent}%`;
|
|
|
|
const progressPattern = /(\*\*Progress:\*\*\s*).*/i;
|
|
if (progressPattern.test(content)) {
|
|
content = content.replace(progressPattern, `$1${progressStr}`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
|
|
} else {
|
|
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
|
|
const { phase, summary, rationale } = options;
|
|
if (!summary) { output({ error: 'summary required' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`;
|
|
|
|
// Find Decisions section (various heading patterns)
|
|
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
const match = content.match(sectionPattern);
|
|
|
|
if (match) {
|
|
let sectionBody = match[2];
|
|
// Remove placeholders
|
|
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
|
|
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
|
content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ added: true, decision: entry }, raw, 'true');
|
|
} else {
|
|
output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
function cmdStateAddBlocker(cwd, text, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
if (!text) { output({ error: 'text required' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const entry = `- ${text}`;
|
|
|
|
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
const match = content.match(sectionPattern);
|
|
|
|
if (match) {
|
|
let sectionBody = match[2];
|
|
sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
|
|
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
|
content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ added: true, blocker: text }, raw, 'true');
|
|
} else {
|
|
output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
function cmdStateResolveBlocker(cwd, text, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
if (!text) { output({ error: 'text required' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
|
|
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
const match = content.match(sectionPattern);
|
|
|
|
if (match) {
|
|
const sectionBody = match[2];
|
|
const lines = sectionBody.split('\n');
|
|
const filtered = lines.filter(line => {
|
|
if (!line.startsWith('- ')) return true;
|
|
return !line.toLowerCase().includes(text.toLowerCase());
|
|
});
|
|
|
|
let newBody = filtered.join('\n');
|
|
// If section is now empty, add placeholder
|
|
if (!newBody.trim() || !newBody.includes('- ')) {
|
|
newBody = 'None\n';
|
|
}
|
|
|
|
content = content.replace(sectionPattern, `${match[1]}${newBody}`);
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ resolved: true, blocker: text }, raw, 'true');
|
|
} else {
|
|
output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
|
|
let content = fs.readFileSync(statePath, 'utf-8');
|
|
const now = new Date().toISOString();
|
|
const updated = [];
|
|
|
|
// Update Last session / Last Date
|
|
let result = stateReplaceField(content, 'Last session', now);
|
|
if (result) { content = result; updated.push('Last session'); }
|
|
result = stateReplaceField(content, 'Last Date', now);
|
|
if (result) { content = result; updated.push('Last Date'); }
|
|
|
|
// Update Stopped at
|
|
if (options.stopped_at) {
|
|
result = stateReplaceField(content, 'Stopped At', options.stopped_at);
|
|
if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
|
|
if (result) { content = result; updated.push('Stopped At'); }
|
|
}
|
|
|
|
// Update Resume file
|
|
const resumeFile = options.resume_file || 'None';
|
|
result = stateReplaceField(content, 'Resume File', resumeFile);
|
|
if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
|
|
if (result) { content = result; updated.push('Resume File'); }
|
|
|
|
if (updated.length > 0) {
|
|
fs.writeFileSync(statePath, content, 'utf-8');
|
|
output({ recorded: true, updated }, raw, 'true');
|
|
} else {
|
|
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
module.exports = {
|
|
stateExtractField,
|
|
stateReplaceField,
|
|
cmdStateLoad,
|
|
cmdStateGet,
|
|
cmdStatePatch,
|
|
cmdStateUpdate,
|
|
cmdStateAdvancePlan,
|
|
cmdStateRecordMetric,
|
|
cmdStateUpdateProgress,
|
|
cmdStateAddDecision,
|
|
cmdStateAddBlocker,
|
|
cmdStateResolveBlocker,
|
|
cmdStateRecordSession,
|
|
cmdStateSnapshot,
|
|
};
|