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