* 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>
2317 lines
96 KiB
TypeScript
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,
|
|
};
|