Files
msd-core/get-shit-done/bin/lib/state.cjs
Tyler Satre c67ab759a7 refactor: split gsd-tools.cjs into 11 domain modules under bin/lib/
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>
2026-02-20 10:40:45 -05:00

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,
};