feat: add YAML frontmatter sync to STATE.md for machine readability
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user