Files
msd-core/src/state.cts
Tom Boucher 46967baae8 fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944) (#952)
* test(#948): add regression tests for no-op write guard and record-session auto-create (#944)

Red before fix: 11/15 tests fail. Green after: 15/15.
Covers zero-match patch byte-identity, milestone_name preservation,
stopped_at frontmatter-wins, record-session auto-create fallback, and
adversarial fixtures (CRLF, empty body, non-canonical labels).

Also registers bug-948-state-noop-write-guard.test.cjs in the state
bucket of lint-test-file-count.allowlist.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944)

Shared root cause: `readModifyWriteStateMd` wrote STATE.md unconditionally
even when the transform produced no change, and `syncStateFrontmatter`
re-derived frontmatter from the possibly-stale body on every write.

Three coordinated fixes in src/state.cts:

1. readModifyWriteStateMd: add no-op guard — when transform result ===
   input content, skip the write entirely (no platformWriteSync, no
   last_updated bump, no frontmatter re-derive). Fixes #948 zero-match
   phantom write and the #944 phantom last_updated bump.

2. syncStateFrontmatter: extend existing-frontmatter preserve logic —
   fall back to existingFm['milestone_name'] / existingFm['milestone']
   when the derived value is the template placeholder 'milestone'
   (getMilestoneInfo returns this literal when it cannot match the
   version in ROADMAP.md); prefer existingFm['stopped_at'] /
   existingFm['paused_at'] over a body-derived value (the frontmatter
   value, written by the canonical record-session path, wins over stale
   historical body lines). Mirrors the fallback already in cmdStateJson.

3. cmdStateRecordSession: when --stopped-at / --resume-file are supplied
   but body labels are absent, DWIM auto-create a canonical ## Session
   section (mirroring how add-decision / add-blocker / record-metric
   auto-create their sections). Never return a silent recorded:false when
   the caller supplied values.

SDK check: no sdk/src/state.ts exists in this repo (the comment in
cmdStateSnapshot references a sibling concern in the TypeScript SDK
codebase, which is a separate repo not present here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: add changeset for PR #952 (fix #948/#944)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#948): correct stopped_at preserve rule; adjust test for sync behaviour

The "always prefer frontmatter stopped_at" rule in syncStateFrontmatter
was too aggressive — it broke phase.complete which intentionally updates
stopped_at in the body and expects syncStateFrontmatter to pick it up.

The primary fix (no-op guard in readModifyWriteStateMd) already prevents
the stale-body-overwrites-frontmatter scenario from #948: the file is not
written when the transform produces no change, so syncStateFrontmatter
never runs on a zero-match patch. The body-derived value can only win when
an actual write occurs, which means the body was legitimately updated.

Reverted to the original #905 rule for stopped_at/paused_at: fall back to
existing frontmatter only when the derived value is absent (empty/null).

Also adjusted the sync-suite test to assert what state sync actually does
(milestone_name preservation) rather than a stopped_at-wins property that
state sync does not have by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#944): update existing session block in place (adversarial review)

HIGH finding: the DWIM auto-create in cmdStateRecordSession was appending
a second ## Session block unconditionally, even when one already existed
with non-canonical content (e.g. a markdown table). Both
buildStateFrontmatter and cmdStateSnapshot read only the FIRST ## Session
block via regex, so the newly-written Stopped at / Resume file values
landed in the second, invisible block — frontmatter stopped_at stayed
stale and state-snapshot returned nulls.

Fix: check for an existing ## Session heading. When one is present,
normalize that section in place by replacing its body with canonical
**Last session:** / **Stopped at:** / **Resume file:** bold-label lines.
Only append a brand-new section when NO ## Session heading exists.

LOW finding: the auto-create scaffold emits **Last session:** but
cmdStateSnapshot only matched **Last Date:**, so session.last_date was
null after auto-create despite a valid timestamp being written.

Fix: extend the lastDateMatch regex in cmdStateSnapshot to also accept
**Last session:** / Last session: (the form the scaffold writes).

Tests: 3 new tests added to bug-948-state-noop-write-guard.test.cjs that
confirmed failure against the previous HEAD and pass after this fix:
- exactly one ## Session block after record-session with non-canonical existing block
- state-snapshot sees correct stopped_at via first Session block (not a duplicate)
- state-snapshot session.last_date is non-null after auto-create on body-less file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#944): improve in-place section replace to cleanly remove old body content

The previous regex `/(^## Session[ \t]*$)([\s\S]*?)(?=\n^## |\n*$)/im`
with a lazy match consumed nothing after the heading, so old non-canonical
body content (e.g. table rows) remained after the new canonical lines.

While functionally correct (parsers found the canonical lines first in the
FIRST ## Session block), it left stale content in the section. Replace with
a negative-lookahead per-line pattern that consumes all content from the
heading up to (but not including) the next ## heading, producing a clean
section with only the canonical bold-label lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:22:29 -04:00

2317 lines
96 KiB
TypeScript

/**
* State — STATE.md operations and progression engine
*
* ADR-457 build-at-publish: the hand-written bin/lib/state.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, output, error } = core;
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, planningPaths } = planningWorkspace;
import { realClock } from './clock.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import scanPhasePlans = require('./plan-scan.cjs');
import {
computeProgressPercent,
normalizeProgressNumbers,
normalizeStateStatus,
shouldPreserveExistingProgress,
stateExtractField,
stateReplaceField,
KNOWN_TEMPLATE_DEFAULTS,
KNOWN_STATUS_PATTERNS,
stateReplaceFieldIfTemplate,
} from './state-document.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
// Local frontmatter type alias matching frontmatter.cts so we can call reconstructFrontmatter
type FrontmatterValue = string | string[] | Record<string, unknown>;
type Frontmatter = Record<string, FrontmatterValue>;
interface StateLockClock {
now(): number;
sleep(ms: number): void;
}
interface ReadModifyWriteOptions {
resync?: boolean;
}
interface StateRecordMetricOptions {
phase: string;
plan: string;
duration: string;
tasks?: string | number;
files?: string | number;
}
interface StateAddDecisionOptions {
phase?: string;
summary?: string;
summary_file?: string;
rationale?: string;
rationale_file?: string;
}
interface StateAddBlockerOptions {
text?: string;
text_file?: string;
}
interface StateRecordSessionOptions {
stopped_at?: string;
resume_file?: string | null;
}
interface StateSnapshotSession {
last_date: string | null;
stopped_at: string | null;
resume_file: string | null;
}
interface StatePruneOptions {
keepRecent?: number | string;
dryRun?: boolean;
silent?: boolean;
}
interface StateSyncOptions {
verify?: boolean;
}
interface PrunedSection {
section: string;
count: number;
lines: string[];
}
// ─── Cache ────────────────────────────────────────────────────────────────────
// 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<string, {
totalPhases: number;
completedPhases: number;
totalPlans: number;
completedPlans: number;
}>();
// 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<string>();
process.on('exit', () => {
for (const lockPath of _heldStateLocks) {
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
}
});
// Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
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;
function cmdStateLoad(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const planDir = planningPaths(cwd).planning;
const stateRaw = platformReadSync(path.join(planDir, 'STATE.md')) || '';
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 as Record<string, string | boolean | undefined>;
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, false, undefined);
}
function cmdStateGet(cwd: string, section: string | undefined, raw: boolean): void {
const statePath = planningPaths(cwd).state;
const content = platformReadSync(statePath);
if (content === null) {
error('STATE.md not found');
return;
}
{
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, '');
}
}
function readTextArgOrFile(cwd: string, value: string | undefined, filePath: string | undefined, label: string): string | undefined {
if (!filePath) return value;
// Path traversal guard: ensure file resolves within project directory
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { validatePath } = require('./security.cjs') as { validatePath(filePath: unknown, baseDir: unknown, opts?: { allowAbsolute?: boolean }): { safe: boolean; resolved: string; error?: string } };
const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
if (!pathCheck.safe) {
throw new Error(`${label} path rejected: ${pathCheck.error as string}`);
}
try {
return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
} catch {
throw new Error(`${label} file not found: ${filePath}`);
}
}
function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolean): void {
// Validate all field names before processing
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { validateFieldName } = require('./security.cjs') as { validateFieldName(field: unknown): { valid: boolean; error?: string } };
for (const field of Object.keys(patches)) {
const fieldCheck = validateFieldName(field);
if (!fieldCheck.valid) {
error(`state patch: ${fieldCheck.error as string}`);
}
}
const statePath = planningPaths(cwd).state;
try {
const results: { updated: string[]; failed: string[] } = { 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 result = stateReplaceField(content, field, value);
if (result) {
content = result;
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: string, field: string | undefined, value: string | undefined): void {
if (!field || value === undefined) {
error('field and value required for state update');
}
// Validate field name to prevent regex injection via crafted field names
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { validateFieldName } = require('./security.cjs') as { validateFieldName(field: unknown): { valid: boolean; error?: string } };
const fieldCheck = validateFieldName(field);
if (!fieldCheck.valid) {
error(`state update: ${fieldCheck.error as string}`);
}
const statePath = planningPaths(cwd).state;
try {
let updated = false;
const shouldResync = ['Progress', 'Total Plans in Phase', 'Total Phases'].includes(field as string);
// Preserve curated progress for body-only updates, but allow fields that
// directly project into progress.* frontmatter to rebuild after mutation.
readModifyWriteStateMd(statePath, (content) => {
const body = stripFrontmatter(content);
const result = stateReplaceField(body, field as string, value as string);
if (result) {
updated = true;
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
if (Object.keys(existingFm).length > 0) {
return `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}`;
}
return result;
}
return content;
}, cwd, { resync: shouldResync });
if (updated) {
output({ updated: true }, false, undefined);
} else {
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md` }, false, undefined);
}
} catch {
output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
}
}
// ─── State Progression Engine ────────────────────────────────────────────────
/**
* 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: string, primary: string, fallback: string | null | undefined, value: string): string {
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: string, fields: { status?: string; lastActivity?: string; plan?: string }): string {
const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const posMatch = content.match(posPattern);
if (!posMatch) return content;
let posBody = posMatch[2];
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
if (fields.status && /^Status:/m.test(posBody)) {
// Only replace when the existing Current Position Status is a known template default.
const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
const matchesPattern = existingStatus && KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
const isDefault = !existingStatus || isInList || matchesPattern;
if (isDefault) {
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
}
}
if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
// Only replace when the existing Current Position Last activity is a known template
// default (a bare ISO date). Executor-authored narrative prose is preserved.
const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
// A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
// A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
// was authored by the executor and must be preserved.
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
const isDefault = !existingActivity || isDateShape || inList;
if (isDefault) {
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: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const today = realClock.today();
let result: Record<string, unknown> | null = 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: number, totalPlans: number;
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;
} else {
currentPlan = NaN;
totalPlans = NaN;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: true };
return content;
}
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
if (currentPlan >= totalPlans) {
// Phase-complete branch — only replace Status/Last Activity when the existing
// value is a known template default (Knuth invariant: preserve executor-authored).
content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
// stateReplaceFieldWithFallback tries 'Last activity' alias too
content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, 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: string;
if (useCompoundFormat) {
// Preserve compound format: "X of Y in current phase" → replace X only
planDisplayValue = (planField as string).replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
// Normal advance — only replace Status/Last Activity when the existing value is
// a known template default (Knuth invariant: preserve executor-authored).
content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, 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 as Record<string, unknown>)['error']) {
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
return;
}
if ((result as Record<string, unknown>)['advanced'] === false) {
output(result, raw, 'false');
} else {
output(result, raw, 'true');
}
}
function cmdStateRecordMetric(cwd: string, options: StateRecordMetricOptions, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const { phase, plan, duration, tasks, files } = options;
if (!phase || !plan || !duration) {
output({ error: 'phase, plan, and duration required' }, raw, undefined);
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: string) => `${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: Record<string, unknown> = { recorded: true, phase, plan, duration };
if (created) result['created'] = true;
output(result, raw, 'true');
}
function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); 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) as (dir: string) => boolean;
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 = '█'.repeat(filled) + '░'.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: string) => `${prefix}${progressStr}`);
} else if (plainProgressPattern.test(content)) {
updated = true;
return content.replace(plainProgressPattern, (_match, prefix: string) => `${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: string, options: StateAddDecisionOptions, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const { phase, summary, summary_file, rationale, rationale_file } = options;
let summaryText: string | undefined = undefined;
let rationaleText = '';
try {
summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale') || '';
} catch (err) {
output({ added: false, reason: (err as Error).message }, raw, 'false');
return;
}
if (!summaryText) { output({ error: 'summary required' }, raw, undefined); 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: string) => `${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: Record<string, unknown> = { added: true, decision: entry };
if (created) result['created'] = true;
output(result, raw, 'true');
}
function cmdStateAddBlocker(cwd: string, text: string | StateAddBlockerOptions, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const blockerOptions: StateAddBlockerOptions = typeof text === 'object' && text !== null ? text : { text: text };
let blockerText: string | undefined = undefined;
try {
blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
} catch (err) {
output({ added: false, reason: (err as Error).message }, raw, 'false');
return;
}
if (!blockerText) { output({ error: 'text required' }, raw, undefined); 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: string) => `${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: Record<string, unknown> = { added: true, blocker: blockerText };
if (created) result['created'] = true;
output(result, raw, 'true');
}
function cmdStateResolveBlocker(cwd: string, text: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
if (!text) { output({ error: 'text required' }, raw, undefined); 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: string) => `${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: string, options: StateRecordSessionOptions, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const now = realClock.nowIso();
const updated: string[] = [];
let sessionCreated = false;
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 — only when the caller explicitly passed a value OR the
// existing value is a known template default. An executor-authored path must
// not be silently replaced with 'None' just because --resume-file was omitted
// (Knuth invariant: handler-owns-transition-between-known-template-defaults).
const resumeFileDefaults = KNOWN_TEMPLATE_DEFAULTS['Resume File'];
if (options.resume_file !== undefined && options.resume_file !== null) {
// Caller explicitly passed a value — always honour it.
result = stateReplaceField(content, 'Resume File', options.resume_file);
if (!result) result = stateReplaceField(content, 'Resume file', options.resume_file);
if (result) { content = result; updated.push('Resume File'); }
} else {
// No explicit value — only set 'None' when existing value is also a known default
// (i.e. not executor-authored).
const newRf = stateReplaceFieldIfTemplate(content, 'Resume File', resumeFileDefaults, 'None');
if (newRf !== content) {
content = newRf;
updated.push('Resume File');
} else {
// Try alternate capitalisation
const newRfAlt = stateReplaceFieldIfTemplate(content, 'Resume file', resumeFileDefaults, 'None');
if (newRfAlt !== content) {
content = newRfAlt;
updated.push('Resume File');
}
}
}
// Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
// --resume-file but the body lacks the canonical labels (in-place replace
// returned a miss), persist the values durably. Mirrors the DWIM pattern used
// by add-decision, add-blocker, and record-metric. Never silently drop
// caller-supplied values.
//
// Guard: only act when the caller actually supplied a value. When no
// --stopped-at / --resume-file are given and the body already had no session
// labels (nothing was updated), we return recorded:false — the existing
// behaviour for a no-op call that didn't supply any values.
//
// Correctness invariant: both buildStateFrontmatter and cmdStateSnapshot read
// only the FIRST `## Session` block (via a /##\s*Session\s*\n…/i regex).
// If we blindly append a second `## Session` block when one already exists, the
// newly-written Stopped at / Resume file end up in the second (invisible) block.
// Fix: when a `## Session` heading already exists, normalize THAT block in place
// (insert / replace canonical bold-label lines within the existing section).
// Only append a brand-new section when NO `## Session` heading exists at all.
const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
? options.resume_file
: 'None';
const stoppedAtValue = options.stopped_at || 'None';
// Determine whether a ## Session heading already exists in the body.
const existingSessionHeading = /^## Session\s*$/im.test(content);
if (existingSessionHeading) {
// Normalize in place: replace the ENTIRE BODY of the existing ## Session
// section (heading + all content up to the next ## heading or EOF) with
// canonical bold-label lines. The negative-lookahead per-line pattern
// `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
// which correctly stops at the next section boundary without consuming it.
// A trailing blank line is added so the next ## heading keeps its spacing.
content = content.replace(
/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m,
[
'## Session',
'',
`**Last session:** ${now}`,
`**Stopped at:** ${stoppedAtValue}`,
`**Resume file:** ${resumeValue}`,
'',
'',
].join('\n'),
);
} else {
// No ## Session heading exists at all — append a new canonical section.
const scaffold = [
'',
'## Session',
'',
`**Last session:** ${now}`,
`**Stopped at:** ${stoppedAtValue}`,
`**Resume file:** ${resumeValue}`,
'',
].join('\n');
content = content.trimEnd() + '\n' + scaffold;
}
sessionCreated = true;
if (needsLastSession) updated.push('Last session');
if (needsStoppedAt) updated.push('Stopped At');
if (needsResumeFile) updated.push('Resume File');
}
return content;
}, cwd);
if (updated.length > 0) {
const result: Record<string, unknown> = { recorded: true, updated };
if (sessionCreated) result['created'] = true;
output(result, raw, 'true');
} else {
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
}
}
function cmdStateSnapshot(cwd: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
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) as Record<string, unknown>;
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: string): string | null => {
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: Array<{ phase: string; summary: string; rationale: string }> = [];
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: string[] = [];
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: StateSnapshotSession = {
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];
// Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
// (the form written by the DWIM auto-create / normalize path added for #944).
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last Date:\s*(.+)/im)
|| sessionSection.match(/\*\*Last session:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last session:\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, undefined);
}
// ─── 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: string, cwd: string | undefined): Record<string, unknown> {
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: string | null = null;
let milestoneName: string | null = null;
if (cwd) {
try {
const info = getMilestoneInfo(cwd);
milestone = info.version;
milestoneName = info.name;
} catch { /* intentionally empty */ }
}
let totalPhases: number | null = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
let completedPhases: number | null = null;
let totalPlans: number | null = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
let completedPlans: number | null = 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) as (dir: string) => boolean;
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<string, string>(); // 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) as string);
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++;
}
// Count phase headings from ROADMAP using a digit-containing pattern
// that matches both numeric phases (01, 05.1) and project-code phases
// (PROJ-42, CK-05) but excludes pure-word section headers like
// `## Phase Overview:` or `## Phase Details:` — single source of
// truth for total_phases (#549).
let roadmapPhaseCount = 0;
try {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = platformReadSync(roadmapPath);
if (roadmapRaw !== null) {
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
// Only count tokens that contain at least one digit — excludes
// pure-word section headings (Overview, Details) while keeping
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
if (/\d/.test(m[1])) roadmapPhaseCount++;
}
}
} catch { /* fall through: phaseDirs.length used as sole count */ }
cached = {
totalPhases: roadmapPhaseCount > 0
? Math.max(phaseDirs.length, roadmapPhaseCount)
: 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);
}
const normalizedStatus = normalizeStateStatus(status, pausedAt);
const fm: Record<string, unknown> = { 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'] = realClock.nowIso();
if (lastActivity) fm['last_activity'] = lastActivity;
const progress: Record<string, unknown> = {};
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: string): string {
// 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;
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: string, cwd: string | undefined): string {
// 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) as Record<string, unknown>;
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'];
}
// Bug #948: preserve `milestone_name` / `milestone` when the derived value
// is the template placeholder 'milestone'. getMilestoneInfo returns the
// literal string 'milestone' when it cannot match the version from the roadmap
// (e.g. no ROADMAP.md, roadmap lacks the heading for the stored version, or the
// milestone version read from STATE.md itself triggers the lookup before the
// file is fully written). A placeholder must never overwrite a real name that the
// existing frontmatter already holds; only an empty derived value falls through
// to this guard (the primary #905 preserve path below handles that).
const MILESTONE_NAME_PLACEHOLDER = 'milestone';
if (
derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
existingFm['milestone_name'] &&
existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER
) {
derivedFm['milestone_name'] = existingFm['milestone_name'];
// Keep the stored milestone version consistent with the preserved name.
if (existingFm['milestone']) {
derivedFm['milestone'] = existingFm['milestone'];
}
}
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
// from body annotations (Current Phase:, Current Plan:, etc.). When those
// annotations are absent — e.g. after an agent or tool rewrites the body —
// buildStateFrontmatter returns no value for those keys. Mirror the same
// fallback pattern used in cmdStateJson so the existing frontmatter values
// survive every writeStateMd call.
//
// For stopped_at / paused_at: the original #905 "fall back when derived is
// absent" rule is preserved here. The stale-body-overwrites-frontmatter
// scenario from #948 is prevented by the no-op guard in
// readModifyWriteStateMd: when the transform produces no change the file is
// never written, so syncStateFrontmatter never even runs. Attempting to
// "always prefer frontmatter" here breaks legitimate callers like phase.complete
// that intentionally write a new stopped_at value to the body and expect
// syncStateFrontmatter to pick it up.
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
derivedFm['stopped_at'] = existingFm['stopped_at'];
}
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
derivedFm['paused_at'] = existingFm['paused_at'];
}
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
derivedFm['current_phase'] = existingFm['current_phase'];
}
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
}
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
derivedFm['current_plan'] = existingFm['current_plan'];
}
// progress is a sub-object: fall back to existing only when the body+disk
// scan produced NO progress block at all. When buildStateFrontmatter did
// derive a progress block (even a lower one), that derived value wins — the
// shouldPreserveExistingProgress cross-milestone logic is applied later in
// cmdStateJson on the read path where it is appropriate.
if (!derivedFm['progress'] && existingFm['progress']) {
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
}
const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
return `---\n${yamlStr}\n---\n\n${body}`;
}
// Transient errno codes that indicate a temporary filesystem condition under
// concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
// (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
// recoverable; acquireStateLock retries instead of propagating them.
// Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and
// will still throw immediately.
const ACQUIRE_LOCK_RETRY_ERRNOS = new Set([
'EPERM', // Windows / macOS AV scanner holds the file open during delete
'EBUSY', // Windows: file in use by another process
'EAGAIN', // POSIX: resource temporarily unavailable
'EINTR', // POSIX: syscall interrupted by signal
'EINVAL', // Docker overlay-fs: transient during concurrent O_EXCL creation
'EIO', // Docker overlay-fs / NFS: transient I/O error
'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
'ESTALE', // NFS: stale file handle (self-resolves on retry)
]);
/**
* Acquire a lockfile for STATE.md operations.
* Returns the lock path for later release.
*
* @param statePath
* @param clock
* Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
* Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
* without real wall-clock waits.
*/
function acquireStateLock(statePath: string, clock?: StateLockClock): string {
if (clock === undefined) clock = realClock;
const lockPath = statePath + '.lock';
const retryDelay = 200; // ms
const staleThresholdMs = 10000;
const maxWaitMs = 30000;
const startedAt = clock.now();
while (true) {
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);
// Exit-time cleanup keeps a crashed locked region from leaving a stale file (#1916).
_heldStateLocks.add(lockPath);
return lockPath;
} catch (err) {
// Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
// are recoverable — retry the acquisition loop rather than propagating.
// See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
if (ACQUIRE_LOCK_RETRY_ERRNOS.has((err as NodeJS.ErrnoException).code as string)) { continue; }
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates
// Only unlink a lock we did not place when it has crossed the staleness
// threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
// writer causes lost updates (#3711 regression).
try {
const stat = fs.statSync(lockPath);
if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
continue;
}
} catch { continue; /* released between EEXIST and stat */ }
if ((clock).now() - startedAt >= maxWaitMs) {
throw new Error(
'acquireStateLock: ' + lockPath + ' held by live process for ' +
((clock).now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)'
);
}
const jitter = Math.floor(Math.random() * 50);
(clock).sleep(retryDelay + jitter);
}
}
}
function releaseStateLock(lockPath: string): void {
_heldStateLocks.delete(lockPath);
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
}
function withStateLock<T>(statePath: string, fn: () => T): T {
const lockPath = acquireStateLock(statePath);
try {
return fn();
} finally {
releaseStateLock(lockPath);
}
}
/**
* 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).
*
* @param statePath
* @param content
* @param cwd
* @param clock
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
*/
function writeStateMd(statePath: string, content: string, cwd?: string, clock?: StateLockClock): void {
// 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, clock);
try {
platformWriteSync(statePath, synced);
} 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 statePath
* @param transformFn - (content: string) => string
* @param cwd
* @param 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.
* @param clock
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
*/
function readModifyWriteStateMd(statePath: string, transformFn: (content: string) => string, cwd: string, options?: ReadModifyWriteOptions, clock?: StateLockClock): void {
const resync = !options || options.resync !== false;
const lockPath = acquireStateLock(statePath, clock);
try {
const content = platformReadSync(statePath) || '';
// Snapshot the existing progress block BEFORE the transform so we can
// restore it when resync is false.
const preFm = resync ? null : extractFrontmatter(content) as Record<string, unknown>;
const modified = transformFn(content);
// Bug #948: no-op guard — if the transform produced no change, do NOT write
// the file. An unconditional write would bump `last_updated`, reset
// `milestone_name` to the template placeholder, and resurrect stale
// body-derived `stopped_at` values via syncStateFrontmatter. Skipping the
// write when content is unchanged is safe because every caller that mutates
// content already returns the mutated string, and callers that detect a
// no-op explicitly return the original content unchanged.
if (modified === content) {
return;
}
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) as Record<string, unknown>;
postFm['progress'] = preFm['progress'];
const yamlStr = reconstructFrontmatter(postFm as unknown as Frontmatter);
const body = stripFrontmatter(synced);
synced = `---\n${yamlStr}\n---\n\n${body}`;
}
platformWriteSync(statePath, synced);
} finally {
releaseStateLock(lockPath);
}
}
function cmdStateJson(cwd: string, raw: boolean): void {
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) as Record<string, unknown>;
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'];
}
// Bug #905: preserve scalar fields when body annotations are absent.
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
built['current_phase'] = existingFm['current_phase'];
}
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
built['current_phase_name'] = existingFm['current_phase_name'];
}
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
built['current_plan'] = existingFm['current_plan'];
}
// Preserve curated cross-milestone aggregates when local disk scanning sees
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
// rebuild from disk because they do not exceed the derived scan.
if (existingFm && shouldPreserveExistingProgress(existingFm['progress'], built['progress'])) {
built['progress'] = normalizeProgressNumbers(existingFm['progress']);
}
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: string, phaseNumber: string | number, phaseName: string | null | undefined, planCount: number | null | undefined, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
return;
}
const today = realClock.today();
const updated: string[] = [];
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: string) => `${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: string, type: string | undefined, question: string | undefined, options: string | undefined, phase: string | undefined, raw: boolean): void {
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: realClock.nowIso(),
phase: phase || null,
};
try {
platformEnsureDir(gsdDir);
platformWriteSync(waitingPath, JSON.stringify(signal, null, 2));
output({ signaled: true, path: waitingPath }, raw, 'true');
} catch (e) {
output({ signaled: false, error: (e as Error).message }, raw, 'false');
}
}
/**
* Remove the WAITING.json signal file when user answers and agent resumes.
*/
function cmdSignalResume(cwd: string, raw: boolean): void {
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 { /* intentionally empty */ }
}
}
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: string, cwd: string, phaseNum: string | number, planCount: number, summaryCount: number): string {
// 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 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, (_match, tableHeader: string) => `${tableHeader}${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: string, phaseNumber: string | number, planCount: number | null | undefined, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
return;
}
const today = realClock.today();
const updated: string[] = [];
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
// plan-phase updates per-phase body fields only. It must NOT resync the
// milestone-wide progress.* frontmatter from a half-planned disk snapshot —
// doing so tramples curated/known-good counters. Route through the body-only
// write contract (resync:false), the same guard state.update uses. (#500 RC1)
readModifyWriteStateMd(statePath, (content) => {
// Update Status — only when the existing value is a known template default
// (Knuth invariant: preserve executor-authored values).
const newContent = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
if (newContent !== content) { content = newContent; updated.push('Status'); }
// Update Total Plans in Phase
if (planCount !== null && planCount !== undefined) {
const result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update Last Activity — only when the existing value is a known template default
{
const after = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
if (after !== content) { content = after; updated.push('Last Activity'); }
}
// Update Last Activity Description
{
const 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`,
});
return content;
}, cwd, { resync: false });
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: string, version: string | undefined, name: string | undefined, raw: boolean): void {
if (!version || !String(version).trim()) {
output({ error: 'milestone required (--milestone <vX.Y>)' }, raw, undefined);
return;
}
const resolvedName = (name && String(name).trim()) || 'milestone';
const statePath = planningPaths(cwd).state;
const today = realClock.today();
const lockPath = acquireStateLock(statePath);
try {
const content = platformReadSync(statePath) || '';
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
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: string;
if (positionPattern.test(body)) {
newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`);
} else {
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
}
const fm: Record<string, unknown> = {
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
milestone: version,
milestone_name: resolvedName,
status: 'planning',
last_updated: realClock.nowIso(),
last_activity: today,
progress: {
total_phases: 0,
completed_phases: 0,
total_plans: 0,
completed_plans: 0,
percent: 0,
},
};
const yamlStr = reconstructFrontmatter(fm as unknown as Frontmatter);
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
platformWriteSync(statePath, assembled);
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: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
const warnings: string[] = [];
const drift: Record<string, unknown> = {};
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, undefined);
}
/**
* 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: string, options: StateSyncOptions | undefined, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
return;
}
const verify = options && options.verify;
const content = fs.readFileSync(statePath, 'utf-8');
const changes: string[] = [];
let modified = content;
const today = realClock.today();
const phasesDir = planningPaths(cwd).phases;
if (!fs.existsSync(phasesDir)) {
output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
return;
}
// Scan all phases
let entries: string[];
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, undefined);
return;
}
let totalDiskPlans = 0;
let totalDiskSummaries = 0;
let diskCompletedPhases = 0;
let highestIncompletePhase: string | null = null;
let _highestIncompletePhaseNum: string | null = 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: number | null = null;
try {
let roadmapPhaseCount = 0;
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = platformReadSync(roadmapPath);
if (roadmapRaw !== null) {
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
// Only count tokens that contain at least one digit — excludes
// pure-word section headings (Overview, Details) while keeping
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
if (/\d/.test(m[1])) roadmapPhaseCount++;
}
}
if (roadmapPhaseCount > 0) {
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
} 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 = '█'.repeat(filled) + '░'.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, undefined);
return;
}
if (changes.length > 0 || modified !== content) {
writeStateMd(statePath, modified, cwd);
}
output({ synced: true, changes, dry_run: false }, raw, undefined);
}
/**
* 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: string, options: StatePruneOptions, raw: boolean): void {
const silent = !!options.silent;
const emit = silent ? () => {} : (result: Record<string, unknown>, r: boolean, v?: string) => output(result, r, v);
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
const dryRun = !!options.dryRun;
const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
const currentPhase = parseInt(currentPhaseRaw as string, 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: PrunedSection[] = [];
// Shared pruning logic applied to both dry-run and real passes.
// Returns { newContent, archivedSections }.
function prunePass(content: string): { newContent: string; archivedSections: PrunedSection[] } {
const sections: PrunedSection[] = [];
// 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: string[] = [];
const archive: string[] = [];
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: string) => `${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: string[] = [];
const archive: string[] = [];
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: string) => `${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: string[] = [];
const archive: string[] = [];
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: string) => `${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: string[] = [];
const archive: string[] = [];
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: string) => `${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 = realClock.today();
let archiveContent = platformReadSync(archivePath);
if (archiveContent === null) {
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`;
}
platformWriteSync(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: string, overridePhase: string | undefined): string | null {
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: string, raw: boolean, overridePhase?: string): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw, undefined);
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, undefined);
return;
}
// Idempotency guard (#3489). If STATE.md's canonical `Current Phase` field
// already names a phase distinct from the one we are being asked to mark
// complete, the project has advanced past the requested phase (e.g. a
// follow-up phase was inserted, or the next phase began). Re-running
// `state complete-phase --phase <N>` in that situation previously rolled
// STATE.md back to <N>'s moment-of-completion — silently clobbering Status,
// Last Activity, Last Activity Description, and the Current Position body.
// The handler is now a no-op in that case so re-invocation from downstream
// workflows cannot regress the project state.
const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || '';
const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
output(
{ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' },
raw,
'false',
);
return;
}
const today = realClock.today();
const updated: string[] = [];
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',
);
}
export = {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
acquireStateLock,
releaseStateLock,
writeStateMd,
readModifyWriteStateMd,
syncStateFrontmatter,
withStateLock,
updatePerformanceMetricsSection,
cmdStateLoad,
cmdStateGet,
cmdStatePatch,
cmdStateUpdate,
cmdStateAdvancePlan,
cmdStateRecordMetric,
cmdStateUpdateProgress,
cmdStateAddDecision,
cmdStateAddBlocker,
cmdStateResolveBlocker,
cmdStateRecordSession,
cmdStateSnapshot,
cmdStateJson,
cmdStateBeginPhase,
cmdStatePlannedPhase,
cmdStateCompletePhase,
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdStateMilestoneSwitch,
cmdSignalWaiting,
cmdSignalResume,
};