Files
msd-core/get-shit-done/bin/lib/state.cjs
Tom Boucher 1a49d2fcfc feat(phase-plans): extract shared scanPhasePlans helper (k014) (#3308)
* test(phase-plans): red — shared scanPhasePlans contract + parity across call sites (#3262)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-plans): extract shared scanPhasePlans helper (k014) (#3262)

Eliminates four divergent copies of the plan-scan algorithm:
- roadmap.cjs:countPhasePlansAndSummaries (root call site)
- state.cjs:buildStateFrontmatter (1 of 3)
- state.cjs:cmdStateValidate (2 of 3)
- state.cjs:cmdStateSync (3 of 3)
- init.cjs:listPhasePlanFiles / listPhaseSummaryFiles

New bin/lib/plan-scan.cjs exports scanPhasePlans(phaseDir) → {
  planCount, summaryCount, completed, hasNestedPlans,
  planFiles, summaryFiles
}

Divergences resolved:
- roadmap.cjs used a broad isPlanFile (any .md containing PLAN in name,
  matching the extended layout 5-PLAN-01-setup.md); canonical helper
  adopts this wider pattern as the reference implementation.
- state.cjs used a strict endsWith(-PLAN.md) filter, missing extended-
  layout root files; now unified with roadmap.cjs semantics.
- init.cjs listPhasePlanFiles used ^PLAN-\d+ for nested, missing
  the -PLAN-\d+ variant state.cjs also matched; helper includes both.
- pre-bounce exclusion broadened to /.pre-bounce.md$/i (any pre-bounce
  file), not just -PLAN.*\.pre-bounce\.md (roadmap form) or flat
  .pre-bounce.md (state form).
- OUTLINE exclusion broadened to /-OUTLINE\.md$/i to catch both
  flat (-PLAN-OUTLINE.md) and nested (PLAN-01-OUTLINE.md) forms.

Sibling audit: no 5th call site found. phase.cjs:looksLikePlanFile is
a diagnostic probe for non-canonical naming (not a counter) — left
in place per its distinct purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changelog): add entry for #3262 scanPhasePlans extraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): add changeset fragment for #3262

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(inventory): add plan-scan.cjs row to INVENTORY.md CLI Modules table (#3262)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): update bug-3128 test + INVENTORY counts for plan-scan.cjs

- Update tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs to verify
  that roadmap.cjs delegates to plan-scan.cjs (require check) and that the
  extended filter lives in plan-scan.cjs as isRootPlanFile with /PLAN/i
- Bump docs/INVENTORY.md CLI Modules headline from 46 to 47 (plan-scan.cjs)
- Regenerate docs/INVENTORY-MANIFEST.json to include cli_modules/plan-scan.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): migrate two missed call sites to scanPhasePlans (k014)

- init.cjs cmdInitExecutePhase: replace inline /-PLAN\.md$/i filter
  with listPhasePlanFiles(path) to honour nested, extended-layout, OUTLINE
  and pre-bounce exclusions (CR finding)
- state.cjs cmdStateUpdateProgress: replace dual /-PLAN\.md$/i and
  /-SUMMARY\.md$/i filters with scanPhasePlans() so the progress-bar
  body field uses the same counts as buildStateFrontmatter frontmatter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3262): correct INVENTORY-MANIFEST.json to tracked files only

Remove 3 untracked local entries from cli_modules so the manifest matches
what CI sees (47 tracked .cjs files, not 50 local). Previous regeneration
ran against the local filesystem which included cjs-command-router-adapter.cjs,
state-document.cjs, and workstream-inventory.cjs (all untracked on this branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:39:31 -04:00

1964 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* State — STATE.md operations and progression engine
*/
const fs = require('fs');
const path = require('path');
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs');
const { planningDir, planningPaths } = require('./planning-workspace.cjs');
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
const scanPhasePlans = require('./plan-scan.cjs');
// Cache disk scan results from buildStateFrontmatter per cwd per process (#1967).
// Avoids re-reading N+1 directories on every state write when the phase structure
// hasn't changed within the same gsd-tools invocation.
const _diskScanCache = new Map();
/**
* Compute the canonical progress percent for STATE.md frontmatter and body.
*
* Uses min(plan_fraction, phase_fraction) when both denominators are > 0.
* This prevents a false "100%" when ROADMAP declares future phases that have no
* disk dirs yet — all plans summarised only means 100% of *realized* work done,
* not 100% of the declared milestone (#3242 Bug B).
*
* @param {number|null} completedPlans
* @param {number|null} totalPlans
* @param {number|null} completedPhases
* @param {number|null} totalPhases - ROADMAP-declared count (>= realised dirs)
* @returns {number|null} 0–100, or null when there is no data
*/
function computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases) {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData) return null;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
/** Shorthand — every state command needs this path */
function getStatePath(cwd) {
return planningPaths(cwd).state;
}
// Track all lock files held by this process so they can be removed on exit.
// process.on('exit') fires even on process.exit(1), unlike try/finally which is
// skipped when error() calls process.exit(1) inside a locked region (#1916).
const _heldStateLocks = new Set();
process.on('exit', () => {
for (const lockPath of _heldStateLocks) {
try { require('fs').unlinkSync(lockPath); } catch { /* already gone */ }
}
});
// Shared helper: extract a field value from STATE.md content.
// Supports both **Field:** bold and plain Field: format.
// Horizontal whitespace only after ':' so YAML keys like `progress:` do not match as `Progress:` (parity with sdk/helpers stateExtractField).
function stateExtractField(content, fieldName) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
function cmdStateLoad(cwd, raw) {
const config = loadConfig(cwd);
const planDir = planningPaths(cwd).planning;
let stateRaw = '';
try {
stateRaw = fs.readFileSync(path.join(planDir, 'STATE.md'), 'utf-8');
} catch { /* intentionally empty */ }
const configExists = fs.existsSync(path.join(planDir, 'config.json'));
const roadmapExists = fs.existsSync(path.join(planDir, '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 = planningPaths(cwd).state;
try {
const content = fs.readFileSync(statePath, 'utf-8');
if (!section) {
output({ content }, raw, content);
return;
}
// Try to find markdown section or field
const fieldEscaped = escapeRegex(section);
// Check for **field:** value (bold format)
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) {
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
return;
}
// Check for field: value (plain format)
const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
const plainMatch = content.match(plainPattern);
if (plainMatch) {
output({ [section]: plainMatch[1].trim() }, raw, plainMatch[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;
// Path traversal guard: ensure file resolves within project directory
const { validatePath } = require('./security.cjs');
const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
if (!pathCheck.safe) {
throw new Error(`${label} path rejected: ${pathCheck.error}`);
}
try {
return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
} catch {
throw new Error(`${label} file not found: ${filePath}`);
}
}
function cmdStatePatch(cwd, patches, raw) {
// Validate all field names before processing
const { validateFieldName } = require('./security.cjs');
for (const field of Object.keys(patches)) {
const fieldCheck = validateFieldName(field);
if (!fieldCheck.valid) {
error(`state patch: ${fieldCheck.error}`);
}
}
const statePath = planningPaths(cwd).state;
try {
const results = { updated: [], failed: [] };
// Use atomic read-modify-write to prevent lost updates from concurrent agents
readModifyWriteStateMd(statePath, (content) => {
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = escapeRegex(field);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
if (boldPattern.test(content)) {
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
}
}
return 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');
}
// Validate field name to prevent regex injection via crafted field names
const { validateFieldName } = require('./security.cjs');
const fieldCheck = validateFieldName(field);
if (!fieldCheck.valid) {
error(`state update: ${fieldCheck.error}`);
}
const statePath = planningPaths(cwd).state;
try {
let updated = false;
// resync: false — cmdStateUpdate only replaces a body text line.
// Triggering syncStateFrontmatter would rebuild progress.* from disk, trampling
// manually-curated cross-milestone counters stored in the frontmatter (#3242 Bug A).
readModifyWriteStateMd(statePath, (content) => {
const fieldEscaped = escapeRegex(field);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
if (boldPattern.test(content)) {
updated = true;
return content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
} else if (plainPattern.test(content)) {
updated = true;
return content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
}
return content;
}, cwd, { resync: false });
if (updated) {
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 ────────────────────────────────────────────────
// stateExtractField is defined above (shared helper) — do not duplicate.
function stateReplaceField(content, fieldName, newValue) {
const escaped = escapeRegex(fieldName);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
}
return null;
}
/**
* Replace a STATE.md field with fallback field name support.
* Tries `primary` first, then `fallback` (if provided), returns content unchanged
* if neither matches. This consolidates the replaceWithFallback pattern that was
* previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
*/
function stateReplaceFieldWithFallback(content, primary, fallback, value) {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
// Neither pattern matched — field may have been reformatted or removed.
// Log diagnostic so template drift is detected early rather than silently swallowed.
process.stderr.write(
`[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`
);
return content;
}
/**
* Update fields within the ## Current Position section of STATE.md.
* This keeps the Current Position body in sync with the bold frontmatter fields.
* Only updates fields that already exist in the section; does not add new lines.
* Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
*/
function updateCurrentPositionFields(content, fields) {
const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const posMatch = content.match(posPattern);
if (!posMatch) return content;
let posBody = posMatch[2];
if (fields.status && /^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
}
if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
}
if (fields.plan && /^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
}
return content.replace(posPattern, `${posMatch[1]}${posBody}`);
}
function cmdStateAdvancePlan(cwd, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
const today = new Date().toISOString().split('T')[0];
let result = null;
readModifyWriteStateMd(statePath, (content) => {
// Try legacy separate fields first, then compound "Plan: X of Y" format
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan, totalPlans;
let useCompoundFormat = false;
if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10);
} else if (planField) {
// Compound format: "2 of 6 in current phase" or "2 of 6"
currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: true };
return content;
}
if (currentPlan >= totalPlans) {
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
} else {
const newPlan = currentPlan + 1;
let planDisplayValue;
if (useCompoundFormat) {
// Preserve compound format: "X of Y in current phase" → replace X only
planDisplayValue = planField.replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
}
return content;
}, cwd);
if (!result || result.error) {
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
return;
}
if (result.advanced === false) {
output(result, raw, 'false');
} else {
output(result, raw, 'true');
}
}
function cmdStateRecordMetric(cwd, options, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
const { phase, plan, duration, tasks, files } = options;
if (!phase || !plan || !duration) {
output({ error: 'phase, plan, and duration required' }, raw);
return;
}
let recorded = false;
let created = false;
readModifyWriteStateMd(statePath, (content) => {
// 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);
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
if (metricsMatch) {
let tableBody = metricsMatch[2].trimEnd();
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
tableBody = newRow;
} else {
tableBody = tableBody + '\n' + newRow;
}
recorded = true;
return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
}
// Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
// then append the row. Matches state begin-phase / advance-plan DWIM behavior.
const scaffold = [
'',
'## Performance Metrics',
'',
'| Phase | Plan | Duration | Notes |',
'|-------|------|----------|-------|',
newRow,
'',
].join('\n');
recorded = true;
created = true;
return content.trimEnd() + '\n' + scaffold;
}, cwd);
// Auto-create fallback guarantees recorded === true; no else branch needed.
const result = { recorded: true, phase, plan, duration };
if (created) result.created = true;
output(result, raw, 'true');
}
function cmdStateUpdateProgress(cwd, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
// Count summaries across current milestone phases only (outside lock — read-only)
const phasesDir = planningPaths(cwd).phases;
let totalPlans = 0;
let totalSummaries = 0;
if (fs.existsSync(phasesDir)) {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone);
for (const dir of phaseDirs) {
const { planCount, summaryCount } = scanPhasePlans(path.join(phasesDir, dir));
totalPlans += planCount;
totalSummaries += summaryCount;
}
}
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}%`;
let updated = false;
const _totalPlans = totalPlans;
const _totalSummaries = totalSummaries;
readModifyWriteStateMd(statePath, (content) => {
// Try **Progress:** bold format first, then plain Progress: format
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
const plainProgressPattern = /^(Progress:\s*).*/im;
if (boldProgressPattern.test(content)) {
updated = true;
return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
} else if (plainProgressPattern.test(content)) {
updated = true;
return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
}
return content;
}, cwd);
if (updated) {
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 = planningPaths(cwd).state;
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; }
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
let added = false;
let created = false;
readModifyWriteStateMd(statePath, (content) => {
// 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';
added = true;
return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
}
// Section absent — DWIM: auto-create canonical ## Decisions scaffold,
// then append the entry. Matches state begin-phase / advance-plan DWIM behavior.
const scaffold = [
'',
'## Decisions',
'',
entry,
'',
].join('\n');
added = true;
created = true;
return content.trimEnd() + '\n' + scaffold;
}, cwd);
// Auto-create fallback guarantees added === true; no else branch needed.
const result = { added: true, decision: entry };
if (created) result.created = true;
output(result, raw, 'true');
}
function cmdStateAddBlocker(cwd, text, raw) {
const statePath = planningPaths(cwd).state;
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; }
const entry = `- ${blockerText}`;
let added = false;
let created = false;
readModifyWriteStateMd(statePath, (content) => {
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';
added = true;
return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
}
// Section absent — DWIM: auto-create canonical ### Blockers scaffold.
const scaffold = [
'',
'### Blockers',
'',
entry,
'',
].join('\n');
added = true;
created = true;
return content.trimEnd() + '\n' + scaffold;
}, cwd);
// Auto-create fallback guarantees added === true; no else branch needed.
const result = { added: true, blocker: blockerText };
if (created) result.created = true;
output(result, raw, 'true');
}
function cmdStateResolveBlocker(cwd, text, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
if (!text) { output({ error: 'text required' }, raw); return; }
let resolved = false;
readModifyWriteStateMd(statePath, (content) => {
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';
}
resolved = true;
return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
}
return content;
}, cwd);
if (resolved) {
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 = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
const now = new Date().toISOString();
const updated = [];
readModifyWriteStateMd(statePath, (content) => {
// 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'); }
return content;
}, cwd);
if (updated.length > 0) {
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 = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
// Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a
// body table cell containing **Status:** Y cannot shadow the authoritative
// frontmatter value. Mirrors the fix in sdk/src/query/state.ts.
const fm = extractFrontmatter(content);
const body = stripFrontmatter(content);
// Helper: return frontmatter scalar value when present and non-empty.
// Accepts strings, numbers, and booleans — coercing non-string primitives to
// their string representation so callers always receive string | null.
// Returns null for missing, null/undefined, or empty-after-trim values so
// the caller falls back to body extraction.
const fmScalar = (key) => {
const v = fm[key];
if (v === null || v === undefined) return null;
if (typeof v === 'string') return v.trim() || null;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return null;
};
// Extract basic fields — frontmatter keys take precedence over body
const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase');
const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name');
const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases');
const currentPlan = fmScalar('current_plan') ?? stateExtractField(body, 'Current Plan');
const totalPlansRaw = fmScalar('total_plans_in_phase') ?? stateExtractField(body, 'Total Plans in Phase');
const status = fmScalar('status') ?? stateExtractField(body, 'Status');
const progressRaw = fmScalar('progress') ?? stateExtractField(body, 'Progress');
const lastActivity = fmScalar('last_activity') ?? stateExtractField(body, 'Last Activity');
const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description');
const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, '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 = body.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 = body.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 = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last Date:\s*(.+)/im);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
|| sessionSection.match(/^Stopped At:\s*(.+)/im);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
|| sessionSection.match(/^Resume File:\s*(.+)/im);
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 currentPhase = stateExtractField(bodyContent, 'Current Phase');
const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
const currentPlan = stateExtractField(bodyContent, 'Current Plan');
const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases');
const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase');
const status = stateExtractField(bodyContent, 'Status');
const progressRaw = stateExtractField(bodyContent, 'Progress');
const lastActivity = stateExtractField(bodyContent, 'Last Activity');
// Bug #2444: scope Stopped At extraction to the ## Session section so that
// historical "Stopped at:" prose elsewhere in the body (e.g. in a
// Session Continuity Archive section) never overwrites the current value.
// Fall back to full-body search only when no ## Session section exists.
const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at');
const pausedAt = stateExtractField(bodyContent, 'Paused At');
let milestone = null;
let milestoneName = null;
if (cwd) {
try {
const info = getMilestoneInfo(cwd);
milestone = info.version;
milestoneName = info.name;
} catch { /* intentionally empty */ }
}
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 = planningPaths(cwd).phases;
if (fs.existsSync(phasesDir)) {
// Use cached disk scan when available — avoids N+1 readdirSync calls
// on repeated buildStateFrontmatter invocations within the same process (#1967)
let cached = _diskScanCache.get(cwd);
if (!cached) {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone);
// Bug #2445: when stale phase dirs from a prior milestone remain in
// .planning/phases/ alongside new dirs with the same phase number,
// de-duplicate by normalized phase number keeping the most recently
// modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
const seenPhaseNums = new Map(); // normalizedNum -> dirName
for (const dir of allMatchingDirs) {
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
const key = m ? m[1].toLowerCase() : dir;
if (!seenPhaseNums.has(key)) {
seenPhaseNums.set(key, dir);
} else {
// Keep the dir that is newer on disk (more likely current milestone)
try {
const existing = path.join(phasesDir, seenPhaseNums.get(key));
const candidate = path.join(phasesDir, dir);
if (fs.statSync(candidate).mtimeMs > fs.statSync(existing).mtimeMs) {
seenPhaseNums.set(key, dir);
}
} catch { /* keep existing on stat error */ }
}
}
const phaseDirs = [...seenPhaseNums.values()];
let diskTotalPlans = 0;
let diskTotalSummaries = 0;
let diskCompletedPhases = 0;
for (const dir of phaseDirs) {
const phaseDir = path.join(phasesDir, dir);
const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
diskTotalPlans += planCount;
diskTotalSummaries += summaryCount;
if (completed) diskCompletedPhases++;
}
cached = {
totalPhases: isDirInMilestone.phaseCount > 0
? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
: phaseDirs.length,
completedPhases: diskCompletedPhases,
totalPlans: diskTotalPlans,
completedPlans: diskTotalSummaries,
};
_diskScanCache.set(cwd, cached);
}
totalPhases = cached.totalPhases;
completedPhases = cached.completedPhases;
totalPlans = cached.totalPlans;
completedPlans = cached.completedPlans;
}
} catch { /* intentionally empty */ }
}
// Derive percent from disk counts when available (ground truth).
// Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
// ROADMAP-declared-but-unrealized future phases cap the reported completion
// instead of a false 100% from plan-only coverage (#3242 Bug B).
// Falls back to the body Progress: field only when no plan files exist on disk.
let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases);
if (progressPercent === null && 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) {
// Strip ALL frontmatter blocks at the start of the file.
// Handles CRLF line endings and multiple stacked blocks (corruption recovery).
// Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
let result = content;
// eslint-disable-next-line no-constant-condition
while (true) {
const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
if (stripped === result) break;
result = stripped;
}
return result;
}
function syncStateFrontmatter(content, cwd) {
// Read existing frontmatter BEFORE stripping — it may contain values
// that the body no longer has (e.g., Status field removed by an agent).
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const derivedFm = buildStateFrontmatter(body, cwd);
// Preserve existing frontmatter status when body-derived status is 'unknown'.
// This prevents a missing Status: field in the body from overwriting a
// previously valid status (e.g., 'executing' → 'unknown').
if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
derivedFm.status = existingFm.status;
}
const yamlStr = reconstructFrontmatter(derivedFm);
return `---\n${yamlStr}\n---\n\n${body}`;
}
/**
* Acquire a lockfile for STATE.md operations.
* Returns the lock path for later release.
*/
function acquireStateLock(statePath) {
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200; // ms
for (let i = 0; i < maxRetries; i++) {
try {
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
// Register for exit-time cleanup so process.exit(1) inside a locked region
// cannot leave a stale lock file (#1916).
_heldStateLocks.add(lockPath);
return lockPath;
} catch (err) {
if (err.code === 'EEXIST') {
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > 10000) {
fs.unlinkSync(lockPath);
continue;
}
} catch { /* lock was released between check — retry */ }
if (i === maxRetries - 1) {
try { fs.unlinkSync(lockPath); } catch {}
return lockPath;
}
const jitter = Math.floor(Math.random() * 50);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelay + jitter);
continue;
}
return lockPath; // non-EEXIST error — proceed without lock
}
}
return statePath + '.lock';
}
function releaseStateLock(lockPath) {
_heldStateLocks.delete(lockPath);
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
}
/**
* Write STATE.md with synchronized YAML frontmatter.
* All STATE.md writes should use this instead of raw writeFileSync.
* Uses a simple lockfile to prevent parallel agents from overwriting
* each other's changes (race condition with read-modify-write cycle).
*/
function writeStateMd(statePath, content, cwd) {
// Invalidate disk scan cache before computing new frontmatter — the write
// may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
// Safe for any calling pattern, not just short-lived CLI processes (#1967).
if (cwd) _diskScanCache.delete(cwd);
const synced = syncStateFrontmatter(content, cwd);
const lockPath = acquireStateLock(statePath);
try {
atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally {
releaseStateLock(lockPath);
}
}
/**
* Atomic read-modify-write for STATE.md.
* Holds the lock across the entire read -> transform -> write cycle,
* preventing the lost-update problem where two agents read the same
* content and the second write clobbers the first.
*
* @param {string} statePath
* @param {function} transformFn - (content: string) => string
* @param {string} cwd
* @param {{ resync?: boolean }} [options]
* resync: when true (default) rebuilds the entire frontmatter from disk after
* the transform. Pass { resync: false } for body-only updates (e.g. state.update
* on a single field) that must not trample manually-curated cross-milestone
* progress.* counters in the frontmatter (#3242 Bug A).
* When resync is false, syncStateFrontmatter still runs to maintain/create the
* frontmatter block, but any existing progress.* sub-keys are preserved from
* the pre-transform file rather than being rebuilt from disk.
*/
function readModifyWriteStateMd(statePath, transformFn, cwd, options) {
const resync = !options || options.resync !== false;
const lockPath = acquireStateLock(statePath);
try {
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
// Snapshot the existing progress block BEFORE the transform so we can
// restore it when resync is false.
const preFm = resync ? null : extractFrontmatter(content);
const modified = transformFn(content);
let synced = syncStateFrontmatter(modified, cwd);
if (!resync && preFm && preFm.progress) {
// Re-apply the curated progress block that syncStateFrontmatter just
// overwrote with disk-derived values. Only restore keys that were present
// in the snapshot — this preserves any new non-progress frontmatter fields
// (e.g., status, current_phase) that syncStateFrontmatter legitimately
// derived from the updated body.
const postFm = extractFrontmatter(synced);
postFm.progress = preFm.progress;
const yamlStr = reconstructFrontmatter(postFm);
const body = stripFrontmatter(synced);
synced = `---\n${yamlStr}\n---\n\n${body}`;
}
atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally {
releaseStateLock(lockPath);
}
}
function cmdStateJson(cwd, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
// Always rebuild from body + disk so progress counters reflect current state.
// Returning cached frontmatter directly causes stale percent/completed_plans
// when SUMMARY files were added after the last STATE.md write (#1589).
const built = buildStateFrontmatter(body, cwd);
// Preserve frontmatter-only fields that cannot be recovered from the body.
if (existingFm && existingFm.stopped_at && !built.stopped_at) {
built.stopped_at = existingFm.stopped_at;
}
if (existingFm && existingFm.paused_at && !built.paused_at) {
built.paused_at = existingFm.paused_at;
}
// Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
built.status = existingFm.status;
}
output(built, raw, JSON.stringify(built, null, 2));
}
/**
* Update STATE.md when a new phase begins execution.
* Updates body text fields (Current focus, Status, Last Activity, Current Position)
* and synchronizes frontmatter via writeStateMd.
* Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
*/
function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const today = new Date().toISOString().split('T')[0];
const updated = [];
readModifyWriteStateMd(statePath, (content) => {
// Idempotency guard (#3127): if the phase is already mid-flight, do NOT
// overwrite execution-progress fields (Current Plan, plan body line,
// Last Activity Description). Only update fields that are safe to
// refresh on resume (Last Activity date, Status if inconsistent).
// A phase is considered mid-flight when Status contains 'Executing Phase N'
// for the current phase number.
const currentStatus = stateExtractField(content, 'Status') || '';
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
// Update Status field
const statusValue = `Executing Phase ${phaseNumber}`;
let result = stateReplaceField(content, 'Status', statusValue);
if (result) { content = result; updated.push('Status'); }
// Update Last Activity (safe to update on resume — tracks when execute-phase ran)
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
if (!isAlreadyExecuting) {
// First-time execution: set all progress fields
// Update Last Activity Description
const activityDesc = `Phase ${phaseNumber} execution started`;
result = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (result) { content = result; updated.push('Last Activity Description'); }
// Update Current Phase
result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
if (result) { content = result; updated.push('Current Phase'); }
// Update Current Phase Name
if (phaseName) {
result = stateReplaceField(content, 'Current Phase Name', phaseName);
if (result) { content = result; updated.push('Current Phase Name'); }
}
// Update Current Plan to 1 (starting from the first plan)
result = stateReplaceField(content, 'Current Plan', '1');
if (result) { content = result; updated.push('Current Plan'); }
// Update Total Plans in Phase
if (planCount) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update **Current focus:** body text line (#1104)
const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
if (focusPattern.test(content)) {
content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
updated.push('Current focus');
}
// Update ## Current Position section (#1104, #1365)
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// Update or insert Phase line
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
posBody = newPhase + '\n' + posBody;
}
// Update or insert Plan line
const newPlan = `Plan: 1 of ${planCount || '?'}`;
if (/^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
} else {
posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
}
// Update Status line if present
const newStatus = `Status: Executing Phase ${phaseNumber}`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
}
content = content.replace(positionPattern, `${header}${posBody}`);
updated.push('Current Position');
}
} else {
// Resume path: only update Last activity timestamp in Current Position
// (do not touch Plan:, stopped_at, progress.percent, or plan counter)
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
content = content.replace(positionPattern, `${header}${posBody}`);
updated.push('Last activity (resume)');
}
}
}
return content;
}, cwd);
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
}
/**
* Write a WAITING.json signal file when GSD hits a decision point.
* External watchers (fswatch, polling, orchestrators) can detect this.
* File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
* Fixes #1034.
*/
function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd);
const waitingPath = path.join(gsdDir, 'WAITING.json');
const signal = {
status: 'waiting',
type: type || 'decision_point',
question: question || null,
options: options ? options.split('|').map(o => o.trim()) : [],
since: new Date().toISOString(),
phase: phase || null,
};
try {
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8');
output({ signaled: true, path: waitingPath }, raw, 'true');
} catch (e) {
output({ signaled: false, error: e.message }, raw, 'false');
}
}
/**
* Remove the WAITING.json signal file when user answers and agent resumes.
*/
function cmdSignalResume(cwd, raw) {
const paths = [
path.join(cwd, '.gsd', 'WAITING.json'),
path.join(planningDir(cwd), 'WAITING.json'),
];
let removed = false;
for (const p of paths) {
if (fs.existsSync(p)) {
try { fs.unlinkSync(p); removed = true; } catch {}
}
}
output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
}
// ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
/**
* Update the ## Performance Metrics section in STATE.md content.
* Increments Velocity totals and upserts a By Phase table row.
* Returns modified content string.
*/
function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
// Update Velocity: Total plans completed
const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
const newTotal = prevTotal + summaryCount;
content = content.replace(
/Total plans completed:\s*(\d+|\[N\])/,
`Total plans completed: ${newTotal}`
);
// Update By Phase table — upsert row for this phase
const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
const byPhaseMatch = content.match(byPhaseTablePattern);
if (byPhaseMatch) {
let tableBody = byPhaseMatch[2].trim();
const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
if (phaseRowPattern.test(tableBody)) {
// Update existing row
tableBody = tableBody.replace(phaseRowPattern, newRow);
} else {
// Remove placeholder row and add new row
tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
}
content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
}
return content;
}
/**
* Gate 3a: Record state after plan-phase completes.
* Updates Status to "Ready to execute", Total Plans, Last Activity.
*/
function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
let content = fs.readFileSync(statePath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
const updated = [];
// Update Status
let result = stateReplaceField(content, 'Status', 'Ready to execute');
if (result) { content = result; updated.push('Status'); }
// Update Total Plans in Phase
if (planCount !== null && planCount !== undefined) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update Last Activity
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
// Update Last Activity Description
result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
if (result) { content = result; updated.push('Last Activity Description'); }
// Update Current Position section
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
});
if (updated.length > 0) {
writeStateMd(statePath, content, cwd);
}
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
}
/**
* Bug #2630: reset STATE.md for a new milestone cycle.
* Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
* the Current Position body. Preserves Accumulated Context.
* Symmetric with the SDK `stateMilestoneSwitch` handler.
*/
function cmdStateMilestoneSwitch(cwd, version, name, raw) {
if (!version || !String(version).trim()) {
output({ error: 'milestone required (--milestone <vX.Y>)' }, raw);
return;
}
const resolvedName = (name && String(name).trim()) || 'milestone';
const statePath = planningPaths(cwd).state;
const today = new Date().toISOString().split('T')[0];
const lockPath = acquireStateLock(statePath);
try {
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const resetPositionBody =
`\nPhase: Not started (defining requirements)\n` +
`Plan: —\n` +
`Status: Defining requirements\n` +
`Last activity: ${today} — Milestone ${version} started\n\n`;
let newBody;
if (positionPattern.test(body)) {
newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
} else {
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
}
const fm = {
gsd_state_version: existingFm.gsd_state_version || '1.0',
milestone: version,
milestone_name: resolvedName,
status: 'planning',
last_updated: new Date().toISOString(),
last_activity: today,
progress: {
total_phases: 0,
completed_phases: 0,
total_plans: 0,
completed_plans: 0,
percent: 0,
},
};
const yamlStr = reconstructFrontmatter(fm);
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8');
output(
{ switched: true, version, name: resolvedName, status: 'planning' },
raw,
'true',
);
} finally {
releaseStateLock(lockPath);
}
}
/**
* Gate 1: Validate STATE.md against filesystem.
* Returns { valid, warnings, drift } JSON.
*/
function cmdStateValidate(cwd, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
const warnings = [];
const drift = {};
const status = stateExtractField(content, 'Status') || '';
const currentPhase = stateExtractField(content, 'Current Phase');
const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const phasesDir = planningPaths(cwd).phases;
// Scan disk for current phase
if (currentPhase && fs.existsSync(phasesDir)) {
const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
if (phaseDir) {
const phaseDirPath = path.join(phasesDir, phaseDir.name);
const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath);
// Check plan count mismatch
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
}
// Check for VERIFICATION.md
const files = fs.readdirSync(phaseDirPath);
const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
for (const vf of verificationFiles) {
try {
const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
drift.verification_status = { state_status: status, verification: 'passed' };
}
} catch { /* intentionally empty */ }
}
// Check if all plans have summaries but status still says executing
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
// Only warn if no verification exists (if verification passed, the above warning covers it)
if (verificationFiles.length === 0) {
warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
}
}
}
} catch { /* intentionally empty */ }
}
const valid = warnings.length === 0;
output({ valid, warnings, drift }, raw);
}
/**
* Gate 2: Sync STATE.md from filesystem ground truth.
* Scans phase dirs, reconstructs counters, progress, metrics.
* Supports --verify for dry-run mode.
*/
function cmdStateSync(cwd, options, raw) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const verify = options && options.verify;
const content = fs.readFileSync(statePath, 'utf-8');
const changes = [];
let modified = content;
const today = new Date().toISOString().split('T')[0];
const phasesDir = planningPaths(cwd).phases;
if (!fs.existsSync(phasesDir)) {
output({ synced: true, changes: [], dry_run: !!verify }, raw);
return;
}
// Scan all phases
let entries;
try {
entries = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
output({ synced: true, changes: [], dry_run: !!verify }, raw);
return;
}
let totalDiskPlans = 0;
let totalDiskSummaries = 0;
let diskCompletedPhases = 0;
let highestIncompletePhase = null;
let highestIncompletePhaseNum = null;
let highestIncompletePhaseplanCount = 0;
let highestIncompletePhaseSummaryCount = 0;
for (const dir of entries) {
const dirPath = path.join(phasesDir, dir);
const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
totalDiskPlans += plans;
totalDiskSummaries += summaries;
if (completed) diskCompletedPhases++;
// Track the highest phase with incomplete plans (or any plans)
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (phaseMatch && plans > 0) {
if (summaries < plans) {
// Incomplete phase — this is likely the current one
highestIncompletePhase = dir;
highestIncompletePhaseNum = phaseMatch[1];
highestIncompletePhaseplanCount = plans;
highestIncompletePhaseSummaryCount = summaries;
} else if (!highestIncompletePhase) {
// All complete, track as potential current
highestIncompletePhase = dir;
highestIncompletePhaseNum = phaseMatch[1];
highestIncompletePhaseplanCount = plans;
highestIncompletePhaseSummaryCount = summaries;
}
}
}
// Determine total phases from ROADMAP (may be larger than realized disk dirs).
// Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
let syncTotalPhases = null;
try {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
if (isDirInMilestone.phaseCount > 0) {
syncTotalPhases = Math.max(entries.length, isDirInMilestone.phaseCount);
} else {
syncTotalPhases = entries.length;
}
} catch { /* intentionally empty */ }
// Sync Total Plans in Phase
if (highestIncompletePhase) {
const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
if (result) modified = result;
}
}
// Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
// computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
// ROADMAP phases cap the reported percent rather than allowing a false 100%.
const percent = (() => {
const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
return p !== null ? p : 0;
})();
const currentProgress = stateExtractField(modified, 'Progress');
if (currentProgress) {
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
if (currentPercent !== percent) {
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
const result = stateReplaceField(modified, 'Progress', progressStr);
if (result) modified = result;
}
}
// Sync Last Activity
const result = stateReplaceField(modified, 'Last Activity', today);
if (result) {
const oldActivity = stateExtractField(modified, 'Last Activity');
if (oldActivity !== today) {
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
}
modified = result;
}
if (verify) {
output({ synced: false, changes, dry_run: true }, raw);
return;
}
if (changes.length > 0 || modified !== content) {
writeStateMd(statePath, modified, cwd);
}
output({ synced: true, changes, dry_run: false }, raw);
}
/**
* Prune old entries from STATE.md sections that grow unboundedly (#1970).
* Moves decisions, recently-completed summaries, and resolved blockers
* older than keepRecent phases to STATE-ARCHIVE.md.
*
* Options:
* keepRecent: number of recent phases to retain (default: 3)
* dryRun: if true, return what would be pruned without modifying STATE.md
*/
function cmdStatePrune(cwd, options, raw) {
const silent = !!options.silent;
const emit = silent ? () => {} : (result, r, v) => output(result, r, v);
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
const keepRecent = parseInt(options.keepRecent, 10) || 3;
const dryRun = !!options.dryRun;
const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
const cutoff = currentPhase - keepRecent;
if (cutoff <= 0) {
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
return;
}
const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
const archived = [];
// Shared pruning logic applied to both dry-run and real passes.
// Returns { newContent, archivedSections }.
function prunePass(content) {
const sections = [];
// Prune Decisions section: entries like "- [Phase N]: ..."
const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const decMatch = content.match(decisionPattern);
if (decMatch) {
const lines = decMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of lines) {
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Decisions', count: archive.length, lines: archive });
content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
// Prune Recently Completed section: entries mentioning phase numbers
const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const recMatch = content.match(recentPattern);
if (recMatch) {
const lines = recMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of lines) {
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
// Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
// or "[RESOLVED]" prefix) with a phase reference older than cutoff
const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const blockersMatch = content.match(blockersPattern);
if (blockersMatch) {
const lines = blockersMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of lines) {
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
// Prune Performance Metrics table rows: keep only rows for phases > cutoff.
// Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const metricsMatch = content.match(metricsPattern);
if (metricsMatch) {
const sectionLines = metricsMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of sectionLines) {
// Table data row: starts with | followed by a number (phase)
const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
if (tableRowMatch) {
const rowPhase = parseInt(tableRowMatch[1], 10);
if (rowPhase <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
} else {
// Header row, separator row, or prose — always keep
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
return { newContent: content, archivedSections: sections };
}
if (dryRun) {
// Dry-run: compute what would be pruned without writing anything
const content = fs.readFileSync(statePath, 'utf-8');
const result = prunePass(content);
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
emit({
pruned: false,
dry_run: true,
cutoff_phase: cutoff,
keep_recent: keepRecent,
sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
total_would_archive: totalPruned,
note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
}, raw, totalPruned > 0 ? 'true' : 'false');
return;
}
readModifyWriteStateMd(statePath, (content) => {
const result = prunePass(content);
archived.push(...result.archivedSections);
return result.newContent;
}, cwd);
// Write archived entries to STATE-ARCHIVE.md
if (archived.length > 0) {
const timestamp = new Date().toISOString().split('T')[0];
let archiveContent = '';
if (fs.existsSync(archivePath)) {
archiveContent = fs.readFileSync(archivePath, 'utf-8');
} else {
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
}
archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
for (const section of archived) {
archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
}
atomicWriteFileSync(archivePath, archiveContent);
}
const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
emit({
pruned: totalPruned > 0,
cutoff_phase: cutoff,
keep_recent: keepRecent,
sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
total_archived: totalPruned,
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
}, raw, totalPruned > 0 ? 'true' : 'false');
}
/**
* Mark the current phase as COMPLETE in STATE.md.
* Updates Status, Last Activity, and the Current Position section to reflect
* that the phase execution is finished and the project is ready for the next phase.
* Implements the `gsd state complete-phase` subcommand (issue #2735).
*/
function resolvePhaseIdForCompletePhase(content, overridePhase) {
const candidate = overridePhase ||
stateExtractField(content, 'Current Phase') ||
stateExtractField(content, 'Phase') ||
'';
// Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2)
const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
return phaseMatch ? phaseMatch[1] : null;
}
function cmdStateCompletePhase(cwd, raw, overridePhase) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw);
return;
}
const today = new Date().toISOString().split('T')[0];
const updated = [];
readModifyWriteStateMd(statePath, (content) => {
const currentPhase = resolvedPhase;
// Update Status field
const statusValue = `Phase ${currentPhase} complete`;
let result = stateReplaceField(content, 'Status', statusValue);
if (result) { content = result; updated.push('Status'); }
// Update Last Activity date
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
// Update Last Activity Description
const activityDesc = `Phase ${currentPhase} marked complete`;
result = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (result) { content = result; updated.push('Last Activity Description'); }
// Update ## Current Position section
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// Update Phase line to show COMPLETE
const newPhase = `Phase: ${currentPhase} — COMPLETE`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
}
// Update Status line if present
const newStatus = `Status: Phase ${currentPhase} complete`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
}
content = content.replace(positionPattern, `${header}${posBody}`);
updated.push('Current Position');
}
return content;
}, cwd);
output(
{ updated, phase: resolvedPhase },
raw,
updated.length > 0 ? 'true' : 'false',
);
}
module.exports = {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
writeStateMd,
readModifyWriteStateMd,
updatePerformanceMetricsSection,
cmdStateLoad,
cmdStateGet,
cmdStatePatch,
cmdStateUpdate,
cmdStateAdvancePlan,
cmdStateRecordMetric,
cmdStateUpdateProgress,
cmdStateAddDecision,
cmdStateAddBlocker,
cmdStateResolveBlocker,
cmdStateRecordSession,
cmdStateSnapshot,
cmdStateJson,
cmdStateBeginPhase,
cmdStatePlannedPhase,
cmdStateCompletePhase,
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdStateMilestoneSwitch,
cmdSignalWaiting,
cmdSignalResume,
};