Files
msd-core/sdk/src/query/state-mutation.ts
Tom Boucher 5676e2e4ef fix(sdk): forward --ws workstream flag through query dispatch (#2546)
* fix(sdk): forward --ws workstream flag through query dispatch (closes #2524)

- cli.ts: pass args.ws as workstream to registry.dispatch()
- registry.ts: add workstream? param to dispatch(), thread to handler
- utils.ts: add optional workstream? to QueryHandler type signature
- helpers.ts: planningPaths() accepts workstream? and uses relPlanningPath()
- All ~26 query handlers updated to receive and pass workstream to planningPaths()
- Config/commit/intel handlers use _workstream (project-global, not scoped)
- Add failing-then-passing test: tests/bug-2524-sdk-query-ws-flag.test.cjs

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

* fix(sdk): forward workstream to all downstream query helpers

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

* fix(test): rewrite #2524 test as static source assertions — no sdk/dist build in CI

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:33:24 -04:00

1446 lines
51 KiB
TypeScript

/**
* STATE.md mutation handlers — write operations with lockfile atomicity.
*
* Ported from get-shit-done/bin/lib/state.cjs.
* Provides STATE.md mutation commands: update, patch, begin-phase,
* advance-plan, record-metric, update-progress, add-decision, add-blocker,
* resolve-blocker, record-session, validate, sync, prune, signal-waiting, signal-resume.
*
* All writes go through readModifyWriteStateMd which acquires a lockfile,
* applies the modifier, syncs frontmatter, normalizes markdown, and writes.
*
* @example
* ```typescript
* import { stateUpdate, stateBeginPhase } from './state-mutation.js';
*
* await stateUpdate(['Status', 'executing'], '/project');
* await stateBeginPhase(['11', 'State Mutations', '3'], '/project');
* ```
*/
import { open, unlink, stat, readFile, writeFile, readdir } from 'node:fs/promises';
import {
constants, unlinkSync, existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync,
} from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js';
import {
comparePhaseNum,
escapeRegex,
normalizePhaseName,
phaseTokenMatches,
planningPaths,
normalizeMd,
stateExtractField,
} from './helpers.js';
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
import type { QueryHandler } from './utils.js';
// ─── Process exit lock cleanup (D2 — match CJS state.cjs:16-23) ─────────
/**
* Module-level set tracking held locks for process.on('exit') cleanup.
* Exported for test access only.
*/
export const _heldStateLocks = new Set<string>();
process.on('exit', () => {
for (const lockPath of _heldStateLocks) {
try { unlinkSync(lockPath); } catch { /* already gone */ }
}
});
// ─── stateReplaceField ────────────────────────────────────────────────────
/**
* Replace a field value in STATE.md content.
*
* Uses separate regex instances (no g flag) to avoid lastIndex persistence.
* Supports both **bold:** and plain: formats.
*
* @param content - STATE.md content
* @param fieldName - Field name to replace
* @param newValue - New value to set
* @returns Updated content, or null if field not found
*/
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
// Try **Field:** bold format first
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'), (_match, prefix: string) => `${prefix}${newValue}`);
}
// Try plain Field: format
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(new RegExp(`(^${escaped}:\\s*)(.*)`, 'im'), (_match, prefix: string) => `${prefix}${newValue}`);
}
return null;
}
/**
* Replace a field with fallback field name support.
*
* Tries primary first, then fallback. Returns content unchanged if neither matches.
*/
function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null, value: string): string {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
return content;
}
/**
* Update fields within the ## Current Position section.
*
* Only updates fields that already exist in the section.
*/
function updateCurrentPositionFields(content: string, fields: Record<string, string | undefined>): 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];
if (fields.status && /^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
}
if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
}
if (fields.plan && /^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
}
return content.replace(posPattern, `${posMatch[1]}${posBody}`);
}
/** Port of `readTextArgOrFile` from `state.cjs` — inline text or file path under project root. */
function readTextArgOrFile(
projectDir: string,
value: string | null | undefined,
filePath: string | null | undefined,
label: string,
): string {
if (!filePath) {
return (value ?? '').trim();
}
const root = resolve(projectDir);
const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
const rel = relative(root, resolved);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`${label} path rejected: outside project directory`);
}
try {
return readFileSync(resolved, 'utf-8').trimEnd();
} catch {
throw new Error(`${label} file not found: ${filePath}`);
}
}
// ─── Lockfile helpers ─────────────────────────────────────────────────────
/**
* If the lock file contains a PID, return whether that process is gone (stolen
* locks after SIGKILL/crash). Null if the file could not be read.
*/
async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
try {
const raw = await readFile(lockPath, 'utf-8');
const pid = parseInt(raw.trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
} catch {
return null;
}
}
/**
* Acquire a lockfile for STATE.md operations.
*
* Uses O_CREAT|O_EXCL for atomic creation. Retries up to 10 times with
* 200ms + jitter delay. Cleans stale locks when the holder PID is dead, or when
* the lock file is older than 10 seconds (existing heuristic).
*
* @param statePath - Path to STATE.md
* @returns Path to the lockfile
*/
export async function acquireStateLock(statePath: string): Promise<string> {
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200;
for (let i = 0; i < maxRetries; i++) {
try {
const fd = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
await fd.writeFile(String(process.pid));
await fd.close();
_heldStateLocks.add(lockPath);
return lockPath;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') {
try {
const dead = await isLockProcessDead(lockPath);
if (dead === true) {
await unlink(lockPath);
continue;
}
const s = await stat(lockPath);
if (Date.now() - s.mtimeMs > 10000) {
await unlink(lockPath);
continue;
}
} catch { /* lock released between check */ }
if (i === maxRetries - 1) {
try { await unlink(lockPath); } catch { /* ignore */ }
return lockPath;
}
await new Promise<void>(r => setTimeout(r, retryDelay + Math.floor(Math.random() * 50)));
} else {
// D3: Graceful degradation on non-EEXIST errors (match CJS state.cjs:889)
return lockPath;
}
}
}
return lockPath;
}
/**
* Release a lockfile.
*
* @param lockPath - Path to the lockfile to release
*/
export async function releaseStateLock(lockPath: string): Promise<void> {
_heldStateLocks.delete(lockPath);
try { await unlink(lockPath); } catch { /* already gone */ }
}
// ─── Frontmatter sync + write helpers ─────────────────────────────────────
/**
* Sync STATE.md content with rebuilt YAML frontmatter.
*
* Strips existing frontmatter, rebuilds from body + disk, and splices back.
* Preserves existing status when body-derived status is 'unknown'.
*/
async function syncStateFrontmatter(content: string, projectDir: string): Promise<string> {
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const derivedFm = await buildStateFrontmatter(body, projectDir);
// Preserve existing status when body-derived is 'unknown'
if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
derivedFm.status = existingFm.status;
}
const yamlStr = reconstructFrontmatter(derivedFm);
return `---\n${yamlStr}\n---\n\n${body}`;
}
/**
* Atomic read-modify-write for STATE.md.
*
* Holds lock across the entire read -> transform -> write cycle.
*
* @param projectDir - Project root directory
* @param modifier - Function to transform STATE.md content
* @returns The final written content
*/
async function readModifyWriteStateMd(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
): Promise<string> {
const statePath = planningPaths(projectDir, workstream).state;
const lockPath = await acquireStateLock(statePath);
try {
let content: string;
try {
content = await readFile(statePath, 'utf-8');
} catch {
content = '';
}
// Strip frontmatter before passing to modifier so that regex replacements
// operate on body fields only (not on YAML frontmatter keys like 'status:').
// syncStateFrontmatter rebuilds frontmatter from the modified body + disk.
const body = stripFrontmatter(content);
const modified = await modifier(body);
const synced = await syncStateFrontmatter(modified, projectDir);
const normalized = normalizeMd(synced);
await writeFile(statePath, normalized, 'utf-8');
return normalized;
} finally {
await releaseStateLock(lockPath);
}
}
/**
* Full-file read-modify-write for STATE.md — matches CJS `readModifyWriteStateMd` in `state.cjs`
* (modifier receives entire file content including YAML frontmatter).
* Used by milestone completion and other flows that replace body fields the same way as the CLI.
*/
export async function readModifyWriteStateMdFull(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
): Promise<void> {
const statePath = planningPaths(projectDir, workstream).state;
const lockPath = await acquireStateLock(statePath);
try {
let content = '';
try {
content = await readFile(statePath, 'utf-8');
} catch {
/* missing */
}
const modified = await modifier(content);
const synced = await syncStateFrontmatter(modified, projectDir);
await writeFile(statePath, normalizeMd(synced), 'utf-8');
} finally {
await releaseStateLock(lockPath);
}
}
// ─── Exported handlers ────────────────────────────────────────────────────
/**
* Query handler for state.update command.
*
* Replaces a single field in STATE.md.
*
* @param args - args[0]: field name, args[1]: new value
* @param projectDir - Project root directory
* @returns QueryResult with { updated: true/false, field, value }
*/
export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => {
const field = args[0];
const value = args[1];
if (!field || value === undefined) {
throw new GSDError('field and value required for state update', ErrorClassification.Validation);
}
let updated = false;
await readModifyWriteStateMd(projectDir, (content) => {
const result = stateReplaceField(content, field, value);
if (result) {
updated = true;
return result;
}
return content;
}, workstream);
return { data: { updated, field, value: updated ? value : undefined } };
};
/**
* Query handler for state.patch command.
*
* Replaces multiple fields atomically in one lock cycle.
*
* @param args - Either `--field value` pairs (CLI / gsd-tools) or a single JSON object string (SDK).
* @param projectDir - Project root directory
* @returns QueryResult with `{ updated, failed }` matching `cmdStatePatch` in `state.cjs`
*/
export const statePatch: QueryHandler = async (args, projectDir, workstream) => {
let patches: Record<string, string>;
if (args.length >= 2 && args[0]?.startsWith('--')) {
patches = {};
for (let i = 0; i < args.length; i += 2) {
const key = args[i]?.replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) patches[key] = value;
}
} else {
const jsonString = args[0];
if (!jsonString) {
throw new GSDError('JSON patches required', ErrorClassification.Validation);
}
try {
patches = JSON.parse(jsonString) as Record<string, string>;
} catch {
throw new GSDError('Invalid JSON for patches', ErrorClassification.Validation);
}
}
const updated: string[] = [];
const failed: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
for (const [field, value] of Object.entries(patches)) {
const result = stateReplaceField(content, field, String(value));
if (result) {
content = result;
updated.push(field);
} else {
failed.push(field);
}
}
return content;
}, workstream);
return { data: { updated, failed } };
};
/**
* Query handler for state.begin-phase command.
*
* Sets phase, plan, status, progress, and current focus fields.
* Rewrites the Current Position section.
*
* Accepts gsd-tools-style argv: `--phase N [--name S] [--plans C]` or positional
* `[phase, name?, planCount?]` (tests and direct handler calls).
*
* @param args - Named or positional phase / name / plan count
* @param projectDir - Project root directory
* @returns QueryResult with phase metadata and `updated` field names (for raw parity)
*/
export const stateBeginPhase: QueryHandler = async (args, projectDir, workstream) => {
const named = parseNamedArgs(args, ['phase', 'name', 'plans']);
let phaseNumber = (named.phase as string | null) || '';
let phaseName = (named.name as string | null) || '';
let plansStr = named.plans as string | null;
const positionalMode = args.length > 0 && !String(args[0]).startsWith('--');
if (positionalMode) {
if (!phaseNumber) phaseNumber = args[0] ?? '';
if (!phaseName) phaseName = (args[1] as string) ?? '';
if (plansStr === null && args[2] !== undefined && !String(args[2]).startsWith('--')) {
plansStr = args[2];
}
}
const plansParsed =
plansStr !== null && plansStr !== '' ? parseInt(String(plansStr), 10) : NaN;
const planNum =
Number.isFinite(plansParsed) && !Number.isNaN(plansParsed) && plansParsed > 0
? plansParsed
: null;
if (!phaseNumber) {
throw new GSDError('phase number required', ErrorClassification.Validation);
}
const today = new Date().toISOString().split('T')[0];
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
// Update bold/plain fields
const statusValue = `Executing Phase ${phaseNumber}`;
let u = stateReplaceField(content, 'Status', statusValue);
if (u) {
content = u;
updated.push('Status');
}
u = stateReplaceField(content, 'Last Activity', today);
if (u) {
content = u;
updated.push('Last Activity');
}
const activityDesc = `Phase ${phaseNumber} execution started`;
u = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (u) {
content = u;
updated.push('Last Activity Description');
}
u = stateReplaceField(content, 'Current Phase', String(phaseNumber));
if (u) {
content = u;
updated.push('Current Phase');
}
if (phaseName) {
u = stateReplaceField(content, 'Current Phase Name', phaseName);
if (u) {
content = u;
updated.push('Current Phase Name');
}
}
u = stateReplaceField(content, 'Current Plan', '1');
if (u) {
content = u;
updated.push('Current Plan');
}
if (planNum !== null && !Number.isNaN(planNum)) {
u = stateReplaceField(content, 'Total Plans in Phase', String(planNum));
if (u) {
content = u;
updated.push('Total Plans in Phase');
}
}
// Update **Current focus:**
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
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 newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
posBody = newPhase + '\n' + posBody;
}
const newPlan = `Plan: 1 of ${planNum ?? '?'}`;
if (/^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
} else {
posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
}
const newStatus = `Status: Executing Phase ${phaseNumber}`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
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');
}
return content;
}, workstream);
return {
data: {
updated,
phase: phaseNumber,
phase_name: phaseName || null,
plan_count: planNum !== null && !Number.isNaN(planNum) ? planNum : null,
},
};
};
/**
* Query handler for state.advance-plan command.
*
* Increments plan counter. Detects phase completion when at last plan.
*
* @param args - unused
* @param projectDir - Project root directory
* @returns QueryResult with { advanced, current_plan, total_plans }
*/
export const stateAdvancePlan: QueryHandler = async (_args, projectDir, workstream) => {
const today = new Date().toISOString().split('T')[0];
let result: Record<string, unknown> = { error: 'STATE.md not found' };
await readModifyWriteStateMd(projectDir, (content) => {
// Parse current plan info (content already has frontmatter stripped)
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan: number;
let totalPlans: number;
let useCompoundFormat = false;
let compoundPlanField: string | null = null;
if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10);
} else if (planField) {
currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true;
compoundPlanField = planField;
} else {
result = { error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' };
return content;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' };
return content;
}
if (currentPlan >= totalPlans) {
// Phase complete
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, {
status: 'Phase complete — ready for verification',
lastActivity: today,
});
result = {
advanced: false,
reason: 'last_plan',
current_plan: currentPlan,
total_plans: totalPlans,
status: 'ready_for_verification',
};
return content;
}
// Advance to next plan
const newPlan = currentPlan + 1;
let planDisplayValue: string;
if (useCompoundFormat && compoundPlanField) {
planDisplayValue = compoundPlanField.replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: today,
plan: planDisplayValue,
});
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
return content;
}, workstream);
return { data: result };
};
/**
* Query handler for state.record-metric command.
*
* Appends a row to the Performance Metrics table.
*
* @param args - gsd-tools argv: `--phase`, `--plan`, `--duration`, `--tasks`, `--files`
* @param projectDir - Project root directory
* @returns QueryResult with { recorded: true/false }
*/
export const stateRecordMetric: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
const phase = parsed.phase as string | null;
const plan = parsed.plan as string | null;
const duration = parsed.duration as string | null;
const tasks = (parsed.tasks as string | null) || '-';
const files = (parsed.files as string | null) || '-';
if (!phase || !plan || !duration) {
return { data: { error: 'phase, plan, and duration required' } };
}
let recorded = false;
await readModifyWriteStateMd(projectDir, (content) => {
const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
const metricsMatch = content.match(metricsPattern);
if (metricsMatch) {
let tableBody = metricsMatch[2].trimEnd();
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`;
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
tableBody = newRow;
} else {
tableBody = tableBody + '\n' + newRow;
}
content = content.replace(metricsPattern, (_match, header: string) => `${header}${tableBody}\n`);
recorded = true;
}
return content;
}, workstream);
if (recorded) {
return { data: { recorded: true, phase, plan, duration } };
}
return { data: { recorded: false, reason: 'Performance Metrics section not found in STATE.md' } };
};
/**
* Query handler for state.update-progress command.
*
* Scans disk to count completed/total plans and updates progress bar.
*
* @param args - unused
* @param projectDir - Project root directory
* @returns QueryResult with { updated, percent, completed, total }
*/
export const stateUpdateProgress: QueryHandler = async (_args, projectDir, workstream) => {
const phasesDir = planningPaths(projectDir, workstream).phases;
let totalPlans = 0;
let totalSummaries = 0;
try {
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const entries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone);
for (const dir of phaseDirs) {
const files = await readdir(join(phasesDir, dir));
totalPlans += files.filter(f => /-PLAN\.md$/i.test(f)).length;
totalSummaries += files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
}
} catch { /* phases dir may not exist */ }
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
let updated = false;
await readModifyWriteStateMd(projectDir, (content) => {
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}`);
}
if (plainProgressPattern.test(content)) {
updated = true;
return content.replace(plainProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`);
}
return content;
}, workstream);
if (updated) {
return { data: { updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr } };
}
return { data: { updated: false, reason: 'Progress field not found in STATE.md' } };
};
/**
* Query handler for state.add-decision command.
*
* Appends a decision to the Decisions section. Removes placeholder text.
* argv matches `gsd-tools.cjs`: `--phase`, `--summary`, `--rationale`, etc.
*/
export const stateAddDecision: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
const phase = parsed.phase as string | null;
let summaryText: string | null = null;
let rationaleText = '';
try {
summaryText = readTextArgOrFile(
projectDir,
(parsed.summary as string | null) ?? null,
(parsed['summary-file'] as string | null) ?? null,
'summary',
);
rationaleText = readTextArgOrFile(
projectDir,
(parsed.rationale as string | null) || '',
(parsed['rationale-file'] as string | null) ?? null,
'rationale',
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: { added: false, reason: msg } };
}
if (!summaryText) {
return { data: { error: 'summary required' } };
}
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
let added = false;
await readModifyWriteStateMd(projectDir, (content) => {
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];
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
added = true;
}
return content;
}, workstream);
if (added) {
return { data: { added: true, decision: entry } };
}
return { data: { added: false, reason: 'Decisions section not found in STATE.md' } };
};
/**
* Query handler for state.add-blocker command.
* argv: `--text`, `--text-file` (see `gsd-tools.cjs`).
*/
export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['text', 'text-file']);
let blockerText: string | null = null;
try {
blockerText = readTextArgOrFile(
projectDir,
(parsed.text as string | null) ?? null,
(parsed['text-file'] as string | null) ?? null,
'blocker',
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: { added: false, reason: msg } };
}
if (!blockerText) {
return { data: { error: 'text required' } };
}
const entry = `- ${blockerText}`;
let added = false;
await readModifyWriteStateMd(projectDir, (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';
content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
added = true;
}
return content;
}, workstream);
if (added) {
return { data: { added: true, blocker: blockerText } };
}
return { data: { added: false, reason: 'Blockers section not found in STATE.md' } };
};
/**
* Query handler for state.resolve-blocker command.
* argv: `--text` (see `gsd-tools.cjs`).
*/
export const stateResolveBlocker: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['text']);
const searchText = parsed.text as string | null;
if (!searchText) {
return { data: { error: 'text required' } };
}
let removedMatchingLine = false;
let blockersSectionFound = false;
await readModifyWriteStateMd(projectDir, (content) => {
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) {
blockersSectionFound = true;
const sectionBody = match[2];
const lines = sectionBody.split('\n');
const filtered = lines.filter(line => {
if (!line.startsWith('- ')) return true;
const matches = line.toLowerCase().includes(searchText.toLowerCase());
if (matches) removedMatchingLine = true;
return !matches;
});
if (!removedMatchingLine) {
return content;
}
let newBody = filtered.join('\n');
if (!newBody.trim() || !newBody.includes('- ')) {
newBody = 'None\n';
}
content = content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`);
}
return content;
}, workstream);
if (removedMatchingLine) {
return { data: { resolved: true, blocker: searchText } };
}
return { data: { resolved: false, reason: blockersSectionFound
? 'Blocker text not found in STATE.md'
: 'Blockers section not found in STATE.md'
} };
};
/**
* Query handler for state.record-session command.
* argv: `--stopped-at`, `--resume-file` (see `cmdStateRecordSession` in `state.cjs`).
*/
export const stateRecordSession: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['stopped-at', 'resume-file']);
const stoppedAt = parsed['stopped-at'] as string | null | undefined;
const resumeFile = ((parsed['resume-file'] as string | null) ?? 'None');
const now = new Date().toISOString();
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
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'); }
if (stoppedAt) {
result = stateReplaceField(content, 'Stopped At', stoppedAt);
if (!result) result = stateReplaceField(content, 'Stopped at', stoppedAt);
if (result) { content = result; updated.push('Stopped At'); }
}
result = stateReplaceField(content, 'Resume File', resumeFile);
if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
if (result) { content = result; updated.push('Resume File'); }
return content;
}, workstream);
if (updated.length > 0) {
return { data: { recorded: true, updated } };
}
return { data: { recorded: false, reason: 'No session fields found in STATE.md' } };
};
/**
* Query handler for state.planned-phase — port of `cmdStatePlannedPhase` from `state.cjs`.
*/
export const statePlannedPhase: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'name', 'plans']);
const phaseNumber = parsed.phase as string | null;
const plansRaw = parsed.plans as string | null;
const parsedPlanCount = plansRaw !== null && plansRaw !== '' ? parseInt(String(plansRaw), 10) : null;
const planCount =
parsedPlanCount !== null &&
!Number.isNaN(parsedPlanCount) &&
Number.isFinite(parsedPlanCount) &&
parsedPlanCount > 0
? parsedPlanCount
: null;
if (!phaseNumber || String(phaseNumber).trim() === '') {
return { data: { error: 'phase required (--phase <n>)' } };
}
const phaseLabel = String(phaseNumber).trim();
const statePath = planningPaths(projectDir, workstream).state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const today = new Date().toISOString().split('T')[0];
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
let result = stateReplaceField(content, 'Status', 'Ready to execute');
if (result) { content = result; updated.push('Status'); }
if (planCount !== null) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
result = stateReplaceField(
content,
'Last Activity Description',
`Phase ${phaseLabel} planning complete — ${planCount ?? '?'} plans ready`,
);
if (result) { content = result; updated.push('Last Activity Description'); }
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: `${today} -- Phase ${phaseLabel} planning complete`,
});
return content;
}, workstream);
return { data: { updated, phase: phaseNumber, plan_count: planCount } };
};
// ─── parseNamedArgs (matches gsd-tools.cjs) ───────────────────────────────
function parseNamedArgs(
args: string[],
valueFlags: string[] = [],
booleanFlags: string[] = [],
): Record<string, string | boolean | null> {
const result: Record<string, string | boolean | null> = {};
for (const flag of valueFlags) {
const idx = args.indexOf(`--${flag}`);
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
? args[idx + 1]
: null;
}
for (const flag of booleanFlags) {
result[flag] = args.includes(`--${flag}`);
}
return result;
}
// ─── Human gate signals (WAITING.json) ───────────────────────────────────
/**
* Port of `cmdSignalWaiting` from state.cjs.
* Args: `--type`, `--question`, `--options` (pipe-separated), `--phase`.
*
* Writes `WAITING.json` under both `.gsd/` and `.planning/` so readers that only
* watch one location (e.g. init workflows) still observe the signal.
*/
export const stateSignalWaiting: QueryHandler = async (args, projectDir, _workstream) => {
const parsed = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
const type = (parsed.type as string | null) || 'decision_point';
const question = (parsed.question as string | null) || null;
const optionsRaw = parsed.options as string | null;
const phase = (parsed.phase as string | null) || null;
const waitingPaths = [
join(projectDir, '.gsd', 'WAITING.json'),
join(projectDir, '.planning', 'WAITING.json'),
];
const signal = {
status: 'waiting',
type,
question,
options: optionsRaw ? optionsRaw.split('|').map(o => o.trim()) : [],
since: new Date().toISOString(),
phase,
};
try {
const payload = JSON.stringify(signal, null, 2);
mkdirSync(join(projectDir, '.gsd'), { recursive: true });
mkdirSync(join(projectDir, '.planning'), { recursive: true });
for (const p of waitingPaths) {
writeFileSync(p, payload, 'utf-8');
}
return { data: { signaled: true, path: waitingPaths[0], paths: waitingPaths } };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { data: { signaled: false, error: msg } };
}
};
/**
* Port of `cmdSignalResume` from state.cjs.
*/
export const stateSignalResume: QueryHandler = async (_args, projectDir, _workstream) => {
const paths = [
join(projectDir, '.gsd', 'WAITING.json'),
join(projectDir, '.planning', 'WAITING.json'),
];
let removed = false;
for (const p of paths) {
if (existsSync(p)) {
try {
unlinkSync(p);
removed = true;
} catch { /* ignore */ }
}
}
return { data: { resumed: true, removed } };
};
// ─── stateValidate ───────────────────────────────────────────────────────
/**
* Port of `cmdStateValidate` from state.cjs.
*/
export const stateValidate: QueryHandler = async (_args, projectDir, workstream) => {
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const content = await readFile(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 = paths.phases;
if (currentPhase && existsSync(phasesDir)) {
const normalized = normalizePhaseName(currentPhase.replace(/\s+of\s+\d+.*/, '').trim());
try {
const entries = readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(
e => e.isDirectory() && phaseTokenMatches(e.name, normalized),
);
if (phaseDir) {
const phaseDirPath = join(phasesDir, phaseDir.name);
const files = readdirSync(phaseDirPath);
const diskPlans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
const diskSummaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
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 };
}
const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
for (const vf of verificationFiles) {
try {
const vContent = readFileSync(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 { /* skip */ }
}
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
if (verificationFiles.length === 0) {
warnings.push(
`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`,
);
}
}
}
} catch { /* skip */ }
}
const valid = warnings.length === 0;
return { data: { valid, warnings, drift } };
};
// ─── stateSync ─────────────────────────────────────────────────────────────
/**
* Port of `cmdStateSync` from state.cjs. Supports `--verify` dry-run.
*/
export const stateSync: QueryHandler = async (args, projectDir, workstream) => {
const verify = args.includes('--verify');
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const content = await readFile(statePath, 'utf-8');
const changes: string[] = [];
const today = new Date().toISOString().split('T')[0];
const phasesDir = paths.phases;
if (!existsSync(phasesDir)) {
return { data: { synced: true, changes: [], dry_run: verify } };
}
let entries: string[];
try {
entries = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort((a, b) => comparePhaseNum(a, b));
} catch {
return { data: { synced: true, changes: [], dry_run: verify } };
}
let totalDiskPlans = 0;
let totalDiskSummaries = 0;
let highestIncompletePhase: string | null = null;
let highestIncompletePhaseplanCount = 0;
for (const dir of entries) {
const dirPath = join(phasesDir, dir);
const files = readdirSync(dirPath);
const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
totalDiskPlans += plans;
totalDiskSummaries += summaries;
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (phaseMatch && plans > 0 && summaries < plans) {
highestIncompletePhase = dir;
highestIncompletePhaseplanCount = plans;
}
}
const runModifier = (modified: string): string => {
let m = modified;
if (highestIncompletePhase) {
const currentPlansField = stateExtractField(m, 'Total Plans in Phase');
if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
const result = stateReplaceField(m, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
if (result) m = result;
}
}
const percent = totalDiskPlans > 0 ? Math.min(100, Math.round((totalDiskSummaries / totalDiskPlans) * 100)) : 0;
const currentProgress = stateExtractField(m, 'Progress');
if (currentProgress) {
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
if (currentPercent !== percent) {
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
const result = stateReplaceField(m, 'Progress', progressStr);
if (result) m = result;
}
}
const oldActivity = stateExtractField(m, 'Last Activity');
const r = stateReplaceField(m, 'Last Activity', today);
if (r) {
if (oldActivity !== today) {
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
}
m = r;
}
return m;
};
if (verify) {
const body = stripFrontmatter(content);
runModifier(body);
return { data: { synced: false, changes, dry_run: true } };
}
await readModifyWriteStateMd(projectDir, (body) => runModifier(body), workstream);
return { data: { synced: true, changes, dry_run: false } };
};
// ─── statePrune ────────────────────────────────────────────────────────────
/**
* Parse phase number from a Performance Metrics table data row.
* Supports `stateRecordMetric` rows (`| Phase 3 P1 | ...`) and legacy `| 3 | ...` rows.
*/
function extractPerformanceMetricsRowPhase(line: string): number | null {
const phaseNamed = line.match(/^\|\s*Phase\s+(\d+)/i);
if (phaseNamed) return parseInt(phaseNamed[1], 10);
const legacy = line.match(/^\|\s*(\d+)\s*\|/);
if (legacy) return parseInt(legacy[1], 10);
return null;
}
interface PruneSection {
section: string;
count: number;
lines: string[];
}
/**
* Port of inner `prunePass` from state.cjs — mutates content string for sections
* older than `cutoff` phase number.
*/
function prunePass(content: string, cutoff: number): { newContent: string; archivedSections: PruneSection[] } {
const archivedSections: PruneSection[] = [];
let contentWork = content;
const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const decMatch = contentWork.match(decisionPattern);
if (decMatch) {
const lines = decMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const pm = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Decisions', count: archive.length, lines: archive });
contentWork = contentWork.replace(decisionPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const recMatch = contentWork.match(recentPattern);
if (recMatch) {
const lines = recMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const pm = line.match(/Phase\s+(\d+)/i);
if (pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
contentWork = contentWork.replace(recentPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const blockersMatch = contentWork.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 pm = line.match(/Phase\s+(\d+)/i);
if (isResolved && pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
contentWork = contentWork.replace(blockersPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const metricsMatch = contentWork.match(metricsPattern);
if (metricsMatch) {
const sectionLines = metricsMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of sectionLines) {
const rowPhase = extractPerformanceMetricsRowPhase(line);
if (rowPhase !== null) {
if (rowPhase <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
contentWork = contentWork.replace(metricsPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
return { newContent: contentWork, archivedSections };
}
/**
* Port of `cmdStatePrune` from state.cjs.
* Args: `--keep-recent N` (default 3), `--dry-run`, `--silent` (omit extra logging fields — no-op in SDK JSON).
*/
export const statePrune: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['keep-recent'], ['dry-run', 'silent']);
const parsedKeepRecent = Number.parseInt(String(parsed['keep-recent'] ?? '3'), 10);
if (!Number.isInteger(parsedKeepRecent) || parsedKeepRecent < 0) {
return { data: { error: 'keep-recent must be a non-negative integer' } };
}
const keepRecent = parsedKeepRecent;
const dryRun = parsed['dry-run'] === true;
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const fullContent = await readFile(statePath, 'utf-8');
const currentPhaseRaw = stateExtractField(fullContent, 'Current Phase');
const currentPhase = parseInt(String(currentPhaseRaw ?? ''), 10) || 0;
const cutoff = currentPhase - keepRecent;
if (cutoff <= 0) {
return {
data: {
pruned: false,
reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}`,
},
};
}
const body = stripFrontmatter(fullContent);
if (dryRun) {
const result = prunePass(body, cutoff);
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
return {
data: {
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',
},
};
}
const archived: PruneSection[] = [];
await readModifyWriteStateMd(projectDir, (b) => {
const result = prunePass(b, cutoff);
archived.push(...result.archivedSections);
return result.newContent;
}, workstream);
const archivePath = join(paths.planning, 'STATE-ARCHIVE.md');
const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
if (archived.length > 0) {
const timestamp = new Date().toISOString().split('T')[0];
let archiveContent = '';
if (existsSync(archivePath)) {
archiveContent = readFileSync(archivePath, 'utf-8');
} else {
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
}
archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
for (const section of archived) {
archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
}
writeFileSync(archivePath, archiveContent, 'utf-8');
}
return {
data: {
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,
},
};
};