feat: add YAML frontmatter sync to STATE.md for machine readability

This commit is contained in:
Lex Christopherson
2026-02-25 07:20:14 -06:00
parent 5cb2e740fd
commit 0ca1a59ab3
6 changed files with 385 additions and 16 deletions

View File

@@ -10,6 +10,7 @@
*
* Atomic Commands:
* state load Load project config + state
* state json Output STATE.md frontmatter as JSON
* state update <field> <value> 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);

View File

@@ -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

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────────