diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 60ff131b3..fa404eb49 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -10,6 +10,7 @@ * * Atomic Commands: * state load Load project config + state + * state json Output STATE.md frontmatter as JSON * state update Update a STATE.md field * state get [section] Get STATE.md content or section * state patch --field val ... Batch update STATE.md fields @@ -177,7 +178,9 @@ async function main() { switch (command) { case 'state': { const subcommand = args[1]; - if (subcommand === 'update') { + if (subcommand === 'json') { + state.cmdStateJson(cwd, raw); + } else if (subcommand === 'update') { state.cmdStateUpdate(cwd, args[2], args[3]); } else if (subcommand === 'get') { state.cmdStateGet(cwd, args[2], raw); diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index dce072567..f7c1ba785 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const { output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); +const { writeStateMd } = require('./state.cjs'); function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) { if (!reqIdsRaw || reqIdsRaw.length === 0) { @@ -169,7 +170,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) { /(\*\*Last Activity Description:\*\*\s*).*/, `$1${version} milestone completed and archived` ); - fs.writeFileSync(statePath, stateContent, 'utf-8'); + writeStateMd(statePath, stateContent, cwd); } // Archive phase directories if requested diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 32127a031..05023fdeb 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); +const { writeStateMd } = require('./state.cjs'); function cmdPhasesList(cwd, options, raw) { const phasesDir = path.join(cwd, '.planning', 'phases'); @@ -675,7 +676,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { const oldTotal = parseInt(ofMatch[2], 10); stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`); } - fs.writeFileSync(statePath, stateContent, 'utf-8'); + writeStateMd(statePath, stateContent, cwd); } const result = { @@ -840,7 +841,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}` ); - fs.writeFileSync(statePath, stateContent, 'utf-8'); + writeStateMd(statePath, stateContent, cwd); } const result = { diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 78c2ed410..2916af9d5 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { loadConfig, output, error } = require('./core.cjs'); +const { loadConfig, getMilestoneInfo, output, error } = require('./core.cjs'); +const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); function cmdStateLoad(cwd, raw) { const config = loadConfig(cwd); @@ -116,7 +117,7 @@ function cmdStatePatch(cwd, patches, raw) { } if (results.updated.length > 0) { - fs.writeFileSync(statePath, content, 'utf-8'); + writeStateMd(statePath, content, cwd); } output(results, raw, results.updated.length > 0 ? 'true' : 'false'); @@ -137,7 +138,7 @@ function cmdStateUpdate(cwd, field, value) { const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); if (pattern.test(content)) { content = content.replace(pattern, (_match, prefix) => `${prefix}${value}`); - fs.writeFileSync(statePath, content, 'utf-8'); + writeStateMd(statePath, content, cwd); output({ updated: true }); } else { output({ updated: false, reason: `Field "${field}" not found in STATE.md` }); @@ -181,14 +182,14 @@ function cmdStateAdvancePlan(cwd, raw) { 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'); + 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; - fs.writeFileSync(statePath, content, 'utf-8'); + writeStateMd(statePath, content, cwd); output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true'); } } @@ -220,7 +221,7 @@ function cmdStateRecordMetric(cwd, options, raw) { } content = content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`); - fs.writeFileSync(statePath, content, 'utf-8'); + 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'); @@ -257,7 +258,7 @@ function cmdStateUpdateProgress(cwd, raw) { const progressPattern = /(\*\*Progress:\*\*\s*).*/i; if (progressPattern.test(content)) { content = content.replace(progressPattern, (_match, prefix) => `${prefix}${progressStr}`); - fs.writeFileSync(statePath, content, 'utf-8'); + 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'); @@ -295,7 +296,7 @@ function cmdStateAddDecision(cwd, options, raw) { 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}`); - fs.writeFileSync(statePath, content, 'utf-8'); + 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'); @@ -328,7 +329,7 @@ function cmdStateAddBlocker(cwd, text, raw) { 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}`); - fs.writeFileSync(statePath, content, 'utf-8'); + 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'); @@ -360,7 +361,7 @@ function cmdStateResolveBlocker(cwd, text, raw) { } content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`); - fs.writeFileSync(statePath, content, 'utf-8'); + 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'); @@ -395,7 +396,7 @@ function cmdStateRecordSession(cwd, options, raw) { if (result) { content = result; updated.push('Resume File'); } if (updated.length > 0) { - fs.writeFileSync(statePath, content, 'utf-8'); + writeStateMd(statePath, content, cwd); output({ recorded: true, updated }, raw, 'true'); } else { output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false'); @@ -503,9 +504,165 @@ function cmdStateSnapshot(cwd, raw) { 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, @@ -518,4 +675,5 @@ module.exports = { cmdStateResolveBlocker, cmdStateRecordSession, cmdStateSnapshot, + cmdStateJson, }; diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index c43e245db..2e0d5db6a 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, output, error } = require('./core.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); +const { writeStateMd } = require('./state.cjs'); function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) { if (!summaryPath) { @@ -725,7 +726,7 @@ function cmdValidateHealth(cwd, options, raw) { stateContent += `**Status:** Resuming\n\n`; stateContent += `## Session Log\n\n`; stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd:health --repair\n`; - fs.writeFileSync(statePath, stateContent, 'utf-8'); + writeStateMd(statePath, stateContent, cwd); repairActions.push({ action: repair, success: true, path: 'STATE.md' }); break; } diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 9e6562e05..0055f4fa5 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -297,6 +297,211 @@ None }); }); +// ───────────────────────────────────────────────────────────────────────────── +// state json command (machine-readable STATE.md frontmatter) +// ───────────────────────────────────────────────────────────────────────────── + +describe('state json command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing STATE.md returns error', () => { + const result = runGsdTools('state json', 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('builds frontmatter on-the-fly from body when no frontmatter exists', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 05 +**Current Phase Name:** Deployment +**Total Phases:** 8 +**Current Plan:** 05-03 +**Total Plans in Phase:** 4 +**Status:** In progress +**Progress:** 60% +**Last Activity:** 2026-01-20 +` + ); + + const result = runGsdTools('state json', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.gsd_state_version, '1.0', 'should have version 1.0'); + assert.strictEqual(output.current_phase, '05', 'current phase extracted'); + assert.strictEqual(output.current_phase_name, 'Deployment', 'phase name extracted'); + assert.strictEqual(output.current_plan, '05-03', 'current plan extracted'); + assert.strictEqual(output.status, 'executing', 'status normalized to executing'); + assert.ok(output.last_updated, 'should have last_updated timestamp'); + assert.strictEqual(output.last_activity, '2026-01-20', 'last activity extracted'); + assert.ok(output.progress, 'should have progress object'); + assert.strictEqual(output.progress.percent, 60, 'progress percent extracted'); + }); + + test('reads existing frontmatter when present', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `--- +gsd_state_version: 1.0 +current_phase: 03 +status: paused +stopped_at: Plan 2 of Phase 3 +--- + +# Project State + +**Current Phase:** 03 +**Status:** Paused +` + ); + + const result = runGsdTools('state json', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.gsd_state_version, '1.0', 'version from frontmatter'); + assert.strictEqual(output.current_phase, '03', 'phase from frontmatter'); + assert.strictEqual(output.status, 'paused', 'status from frontmatter'); + assert.strictEqual(output.stopped_at, 'Plan 2 of Phase 3', 'stopped_at from frontmatter'); + }); + + test('normalizes various status values', () => { + const statusTests = [ + { input: 'In progress', expected: 'executing' }, + { input: 'Ready to execute', expected: 'executing' }, + { input: 'Paused at Plan 3', expected: 'paused' }, + { input: 'Ready to plan', expected: 'planning' }, + { input: 'Phase complete — ready for verification', expected: 'verifying' }, + { input: 'Milestone complete', expected: 'completed' }, + ]; + + for (const { input, expected } of statusTests) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Current Phase:** 01\n**Status:** ${input}\n` + ); + + const result = runGsdTools('state json', tmpDir); + assert.ok(result.success, `Command failed for status "${input}": ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.status, expected, `"${input}" should normalize to "${expected}"`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// STATE.md frontmatter sync (write operations add frontmatter) +// ───────────────────────────────────────────────────────────────────────────── + +describe('STATE.md frontmatter sync', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('state update adds frontmatter to STATE.md', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 02 +**Status:** Ready to execute +` + ); + + const result = runGsdTools('state update Status "Executing Plan 1"', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(content.startsWith('---\n'), 'should start with frontmatter delimiter'); + assert.ok(content.includes('gsd_state_version: 1.0'), 'should have version field'); + assert.ok(content.includes('current_phase: 02'), 'frontmatter should have current phase'); + assert.ok(content.includes('**Current Phase:** 02'), 'body field should be preserved'); + assert.ok(content.includes('**Status:** Executing Plan 1'), 'updated field in body'); + }); + + test('state patch adds frontmatter', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 04 +**Status:** Planning +**Current Plan:** 04-01 +` + ); + + const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 04-02', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(content.startsWith('---\n'), 'should have frontmatter after patch'); + }); + + test('frontmatter is idempotent on multiple writes', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 01 +**Status:** Ready to execute +` + ); + + runGsdTools('state update Status "In progress"', tmpDir); + runGsdTools('state update Status "Paused"', tmpDir); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + const delimiterCount = (content.match(/^---$/gm) || []).length; + assert.strictEqual(delimiterCount, 2, 'should have exactly one frontmatter block (2 delimiters)'); + assert.ok(content.includes('status: paused'), 'frontmatter should reflect latest status'); + }); + + test('round-trip: write then read via state json', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State + +**Current Phase:** 07 +**Current Phase Name:** Production +**Total Phases:** 10 +**Status:** In progress +**Current Plan:** 07-05 +**Progress:** 70% +` + ); + + runGsdTools('state update Status "Executing Plan 5"', tmpDir); + + const result = runGsdTools('state json', tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_phase, '07', 'round-trip: phase preserved'); + assert.strictEqual(output.current_phase_name, 'Production', 'round-trip: phase name preserved'); + assert.strictEqual(output.status, 'executing', 'round-trip: status normalized'); + assert.ok(output.last_updated, 'round-trip: timestamp present'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // summary-extract command // ─────────────────────────────────────────────────────────────────────────────