* 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>
1964 lines
76 KiB
JavaScript
1964 lines
76 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|