/** * State — STATE.md operations and progression engine */ const fs = require('fs'); const path = require('path'); const { loadConfig, getMilestoneInfo, output, error } = require('./core.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.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 readTextArgOrFile(cwd, value, filePath, label) { if (!filePath) return value; const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); try { return fs.readFileSync(resolvedPath, 'utf-8').trimEnd(); } catch { throw new Error(`${label} file not found: ${filePath}`); } } 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, (_match, prefix) => `${prefix}${value}`); results.updated.push(field); } else { results.failed.push(field); } } if (results.updated.length > 0) { writeStateMd(statePath, content, cwd); } 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, (_match, prefix) => `${prefix}${value}`); writeStateMd(statePath, content, cwd); 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 escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\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, (_match, prefix) => `${prefix}${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; writeStateMd(statePath, content, cwd); 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; writeStateMd(statePath, content, cwd); 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) { 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, (_match, header) => `${header}${tableBody}\n`); writeStateMd(statePath, content, cwd); 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.min(100, 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, (_match, prefix) => `${prefix}${progressStr}`); writeStateMd(statePath, content, cwd); 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, summary_file, rationale, rationale_file } = options; let summaryText = null; let rationaleText = ''; try { summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary'); rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale'); } catch (err) { output({ added: false, reason: err.message }, raw, 'false'); return; } if (!summaryText) { output({ error: 'summary required' }, raw); return; } let content = fs.readFileSync(statePath, 'utf-8'); const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`; // 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, header) => `${header}${sectionBody}`); writeStateMd(statePath, content, cwd); 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; } const blockerOptions = typeof text === 'object' && text !== null ? text : { text }; let blockerText = null; try { blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker'); } catch (err) { output({ added: false, reason: err.message }, raw, 'false'); return; } if (!blockerText) { output({ error: 'text required' }, raw); return; } let content = fs.readFileSync(statePath, 'utf-8'); const entry = `- ${blockerText}`; 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, header) => `${header}${sectionBody}`); writeStateMd(statePath, content, cwd); output({ added: true, blocker: blockerText }, 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, header) => `${header}${newBody}`); writeStateMd(statePath, content, cwd); 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) { writeStateMd(statePath, content, cwd); 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); } // ─── State Frontmatter Sync ────────────────────────────────────────────────── /** * Extract machine-readable fields from STATE.md markdown body and build * a YAML frontmatter object. Allows hooks and scripts to read state * reliably via `state json` instead of fragile regex parsing. */ function buildStateFrontmatter(bodyContent, cwd) { const extractField = (fieldName) => { const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); const match = bodyContent.match(pattern); return match ? match[1].trim() : null; }; const currentPhase = extractField('Current Phase'); const currentPhaseName = extractField('Current Phase Name'); const currentPlan = extractField('Current Plan'); const totalPhasesRaw = extractField('Total Phases'); const totalPlansRaw = extractField('Total Plans in Phase'); const status = extractField('Status'); const progressRaw = extractField('Progress'); const lastActivity = extractField('Last Activity'); const stoppedAt = extractField('Stopped At') || extractField('Stopped at'); const pausedAt = extractField('Paused At'); let milestone = null; let milestoneName = null; if (cwd) { try { const info = getMilestoneInfo(cwd); milestone = info.version; milestoneName = info.name; } catch {} } let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; let completedPhases = null; let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; let completedPlans = null; if (cwd) { try { const phasesDir = path.join(cwd, '.planning', 'phases'); if (fs.existsSync(phasesDir)) { const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name); let diskTotalPlans = 0; let diskTotalSummaries = 0; let diskCompletedPhases = 0; for (const dir of phaseDirs) { const files = fs.readdirSync(path.join(phasesDir, dir)); const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length; const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length; diskTotalPlans += plans; diskTotalSummaries += summaries; if (plans > 0 && summaries >= plans) diskCompletedPhases++; } if (totalPhases === null) totalPhases = phaseDirs.length; completedPhases = diskCompletedPhases; totalPlans = diskTotalPlans; completedPlans = diskTotalSummaries; } } catch {} } let progressPercent = null; if (progressRaw) { const pctMatch = progressRaw.match(/(\d+)%/); if (pctMatch) progressPercent = parseInt(pctMatch[1], 10); } // Normalize status to one of: planning, discussing, executing, verifying, paused, completed, unknown let normalizedStatus = status || 'unknown'; const statusLower = (status || '').toLowerCase(); if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) { normalizedStatus = 'paused'; } else if (statusLower.includes('executing') || statusLower.includes('in progress')) { normalizedStatus = 'executing'; } else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) { normalizedStatus = 'planning'; } else if (statusLower.includes('discussing')) { normalizedStatus = 'discussing'; } else if (statusLower.includes('verif')) { normalizedStatus = 'verifying'; } else if (statusLower.includes('complete') || statusLower.includes('done')) { normalizedStatus = 'completed'; } else if (statusLower.includes('ready to execute')) { normalizedStatus = 'executing'; } const fm = { gsd_state_version: '1.0' }; if (milestone) fm.milestone = milestone; if (milestoneName) fm.milestone_name = milestoneName; if (currentPhase) fm.current_phase = currentPhase; if (currentPhaseName) fm.current_phase_name = currentPhaseName; if (currentPlan) fm.current_plan = currentPlan; fm.status = normalizedStatus; if (stoppedAt) fm.stopped_at = stoppedAt; if (pausedAt) fm.paused_at = pausedAt; fm.last_updated = new Date().toISOString(); if (lastActivity) fm.last_activity = lastActivity; const progress = {}; if (totalPhases !== null) progress.total_phases = totalPhases; if (completedPhases !== null) progress.completed_phases = completedPhases; if (totalPlans !== null) progress.total_plans = totalPlans; if (completedPlans !== null) progress.completed_plans = completedPlans; if (progressPercent !== null) progress.percent = progressPercent; if (Object.keys(progress).length > 0) fm.progress = progress; return fm; } function stripFrontmatter(content) { return content.replace(/^---\n[\s\S]*?\n---\n*/, ''); } function syncStateFrontmatter(content, cwd) { const body = stripFrontmatter(content); const fm = buildStateFrontmatter(body, cwd); const yamlStr = reconstructFrontmatter(fm); return `---\n${yamlStr}\n---\n\n${body}`; } /** * Write STATE.md with synchronized YAML frontmatter. * All STATE.md writes should use this instead of raw writeFileSync. */ function writeStateMd(statePath, content, cwd) { const synced = syncStateFrontmatter(content, cwd); fs.writeFileSync(statePath, synced, 'utf-8'); } function cmdStateJson(cwd, raw) { const statePath = path.join(cwd, '.planning', 'STATE.md'); if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, 'STATE.md not found'); return; } const content = fs.readFileSync(statePath, 'utf-8'); const fm = extractFrontmatter(content); if (!fm || Object.keys(fm).length === 0) { const body = stripFrontmatter(content); const built = buildStateFrontmatter(body, cwd); output(built, raw, JSON.stringify(built, null, 2)); return; } output(fm, raw, JSON.stringify(fm, null, 2)); } module.exports = { stateExtractField, stateReplaceField, writeStateMd, cmdStateLoad, cmdStateGet, cmdStatePatch, cmdStateUpdate, cmdStateAdvancePlan, cmdStateRecordMetric, cmdStateUpdateProgress, cmdStateAddDecision, cmdStateAddBlocker, cmdStateResolveBlocker, cmdStateRecordSession, cmdStateSnapshot, cmdStateJson, };