/** * 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 ioMod = require('./io.cjs'); const { output, error } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod; import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath, execGit } 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, stripFrontmatter, propagateCommentChannel } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports import scanPhasePlans = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import verificationMod = require('./verification.cjs'); const { isPhaseComplete } = verificationMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningScopeMod = require('./planning-scope.cjs'); const { SCOPE } = planningScopeMod; type Scope = planningScopeMod.Scope; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { listMilestonePhaseDirs } = phaseLocatorMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import stateTransitionMod = require('./state-transition.cjs'); // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only // node builtins, so it introduces no cycle on this path. import { findProjectRoot } from './project-root.cjs'; const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod; type StateTransitionIntent = stateTransitionMod.StateTransitionIntent; type StateTransitionDeps = stateTransitionMod.StateTransitionDeps; type PhaseInventoryRecord = stateTransitionMod.PhaseInventoryRecord; type PhaseInventoryResult = stateTransitionMod.PhaseInventoryResult; import { computeProgressPercent, normalizeProgressNumbers, normalizeStateStatus, shouldPreserveExistingProgress, stateExtractField, stateFieldValue, stateReplaceField, KNOWN_TEMPLATE_DEFAULTS, stateReplaceFieldIfTemplate, stateCurrentPositionSlice, } from './state-document.cjs'; import { tokenizeHeadings, collectSection, replaceSection } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs'; import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs'; import { textEncodingError } from './validate.cjs'; import { clampPercent } from './phase-lifecycle.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── // Local frontmatter type alias matching frontmatter.cts so we can call reconstructFrontmatter type FrontmatterValue = string | string[] | Record; type Frontmatter = Record; interface StateLockClock { now(): number; sleep(ms: number): void; } interface ReadModifyWriteOptions { resync?: boolean; /** #2440: when true, total_plans/total_phases take derived values even under !resync. */ deriveProgressKeys?: boolean; /** * #2736: intent-first frontmatter values forwarded to syncStateFrontmatter. * Transition adapters that already hold the exact value (e.g. beginPhase's * display name) pass it here so the lossy body-prose re-derivation can never * destroy information the transition just resolved. */ authoritativeFm?: Record; } 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 StateAddRoadmapEvolutionOptions { phase?: string; action?: string; after?: string; note?: string; note_file?: string; urgent?: boolean; } 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 StateRebuildOptions { dryRun?: boolean; verbose?: boolean; silent?: boolean; } interface StateSyncOptions { verify?: boolean; } interface PrunedSection { section: string; count: number; lines: string[]; } const STATE_PROGRESS_RESYNC_FIELDS = new Set([ 'Progress', 'Total Plans in Phase', 'Total Phases', ]); function shouldResyncStateProgress(fields: Iterable): boolean { for (const field of fields) { if (STATE_PROGRESS_RESYNC_FIELDS.has(field)) { return true; } } return false; } // ─── 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(); // Track all lock files held by this process so they can be removed on exit. // process.on('exit') fires even on process.exit(1), unlike try/finally which is // skipped when error() calls process.exit(1) inside a locked region (#1916). const _heldStateLocks = new Set(); process.on('exit', () => { for (const lockPath of _heldStateLocks) { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } } }); // --------------------------------------------------------------------------- // Lock liveness probe (test seam) — audit M1 // // mtime is a LEAKY proxy for "the holder is still alive": a live-but-slow writer // whose critical section runs past staleThresholdMs ages out and a waiter would // steal its lock → two writers in STATE.md's read-modify-write window → lost // update / corruption (the recurring #500/#905/#1230 family). The real signal — // process.kill(pid, 0) — is already used by capability-lock.cts. We backport it // here. The indirection lets unit tests inject a deterministic isPidAlive without // real pids (mirrors capability-lock's _lockProbes / _setLockProbes seam). // --------------------------------------------------------------------------- /** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */ function _realIsPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; // signalable → alive } catch (err) { // EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone. return (err as NodeJS.ErrnoException).code === 'EPERM'; } } const _stateLockProbes: { isPidAlive: (pid: number) => boolean } = { isPidAlive: _realIsPidAlive }; // --------------------------------------------------------------------------- // State-lock test hooks (test seam) — audit M8 / M9 // // Both M8 (scan-before-lock TOCTOU in writeStateMd) and M9 (orphan empty lock + // fd leak on a recoverable writeSync/closeSync error in acquireStateLock) are // concurrency / resource-safety issues a single-threaded test cannot otherwise // observe. These purpose-built hooks make the failure windows deterministic // (mirrors the M1 _setLockProbes seam above): // // afterAcquire(lockPath) — fired inside writeStateMd immediately AFTER the lock // is acquired. A test can mutate the disk here (simulate a concurrent writer // landing in the scan→lock window) to prove the disk scan runs INSIDE the lock. // simulateWriteError — a ONE-SHOT errno string. When set, the next writeSync // inside acquireStateLock throws it (and the hook self-clears), forcing the // openSync-succeeds-then-write-fails cleanup path without an OS-level fault. // onLoopIteration(ctx) — fired at the TOP of each acquireStateLock retry // iteration so a test can snapshot whether an orphan lock is stranded. // beforeSteal(ctx) — fired AFTER the steal decision but BEFORE the identity // re-confirm + atomic rename-steal. A test can recreate a fresh lock here to // simulate a racer winning the steal in the decision→steal gap, proving the // identity re-confirm aborts a double-steal (PR #1532 review window b). // // All hooks default to no-ops; real callers are byte-for-behaviour unchanged. // --------------------------------------------------------------------------- interface StateLockTestHooks { afterAcquire?: (lockPath: string) => void; simulateWriteError?: string | null; onLoopIteration?: (ctx: { iteration: number }) => void; beforeSteal?: (ctx: { lockPath: string }) => void; } const _stateLockTestHooks: StateLockTestHooks = {}; /** * Consume the one-shot simulateWriteError errno, if set. Returns an Error with the * configured `.code` and self-clears so only the NEXT writeSync throws (the retry * then succeeds). Returns null when no injection is pending. */ function _consumeSimulatedWriteError(): NodeJS.ErrnoException | null { const code = _stateLockTestHooks.simulateWriteError; if (!code) return null; _stateLockTestHooks.simulateWriteError = null; // one-shot const e = new Error('simulated writeSync failure (' + code + ')') as NodeJS.ErrnoException; e.code = code; return e; } function _stateLockIsPidAlive(pid: number): boolean { return _stateLockProbes.isPidAlive(pid); } /** * Is the holder recorded in the lock body VERIFIED-LIVE? The STATE.md lock body is * a bare pid (written at acquire time). Returns true ONLY when the body parses to a * positive integer pid AND that pid signals alive. A garbage / non-numeric / legacy * body (or a dead pid) is NOT verified-live, so the lock stays stealable — corrupt * locks never block forever, and a live holder is never stolen. */ function _stateHolderVerifiedLive(lockPath: string): boolean { const pid = _stateLockBodyPid(lockPath); return pid !== null && _stateLockIsPidAlive(pid); } /** * Three-way classification of a lock body read (issue #3057 B2): a pid that * parses cleanly, a body that reads but is empty/garbage/non-numeric, or a * body that could not be READ at all (I/O fault — permission error, transient * NFS/overlay-fs hiccup, mid-rename, etc.). The third case is NOT the same as * the second: an unreadable body tells us nothing about whether the lock is * fresh, stale, or actively held mid-write by a live process whose file the * fault merely prevented us from reading. Collapsing it into "empty" would * make it eligible for the short fresh-create-floor steal window, which can * rob an active holder purely because of a transient read fault. */ type LockBodyStatus = | { kind: 'pid'; pid: number } | { kind: 'empty' } | { kind: 'unreadable' }; /** * Read + classify the lock body at `lockPath`. See `LockBodyStatus` for the * three-way distinction the steal decision in `acquireStateLock` relies on. */ function _stateLockBodyStatus(lockPath: string): LockBodyStatus { let body: string; try { body = fs.readFileSync(lockPath, 'utf-8'); } catch { return { kind: 'unreadable' }; } const trimmed = body.trim(); const pid = parseInt(trimmed, 10); if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== trimmed) return { kind: 'empty' }; return { kind: 'pid', pid }; } /** * Parse the lock body to its recorded pid, or null when the body is empty / non-numeric * / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal * promptly) from an EMPTY/unparseable one (the create→write window — do not steal while * fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision * in acquireStateLock reads the pid directly (PR #1532 review, window a). * * NOTE: this collapses "genuinely empty" and "unreadable" to the same `null` — * that is fine for `_stateHolderVerifiedLive` (both mean "not verified-live" * either way), but the STEAL-TIMING decision must not make that same * collapse (#3057 B2) and reads `_stateLockBodyStatus` directly instead. */ function _stateLockBodyPid(lockPath: string): number | null { const status = _stateLockBodyStatus(lockPath); return status.kind === 'pid' ? status.pid : null; } // Monotonic sequence for unique stale-steal rename targets (no crypto dependency). let _stateStealSeq = 0; // The `byPhaseTablePattern` regex hoisted here for #320 (canonical-column- // ORDER-only By-Phase table match) is retired (#2245 audit): its last caller // — updatePerformanceMetricsSection's row-INSERT branch — now locates the // table via findTableStartOffset/insertTableRow, name-addressed and // header-order-agnostic like the update/sum halves of the same function. // ─── ADR-1372 T6: seam-based section splice helper ─────────────────────────── // Shared stop predicates corresponding to the regex lookaheads used in state.cts: // STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2 // STOP_H2_H3 : (?=\n###?|\n##[^#]|$) — stops at level 2 or 3 // STOP_H2_ONLY : (?=\n##[^#]|$) — stops at level 2 only const STOP_H2_PLUS = (lv: number): boolean => lv >= 2; const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3; const STOP_H2_ONLY = (lv: number): boolean => lv === 2; function cmdStateLoad(cwd: string, raw: boolean): void { const config = loadConfig(cwd); const paths = planningPaths(cwd); const planDir = paths.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, // #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a // spawned subagent's own cwd may differ from the orchestrator's. // #3149: debug.md now has its own `init.debug` entry point and reads this // field from there, not from `state load`. This stays on the state.load // bundle regardless: it is a shipped query surface with its own test anchor // (tests/state.test.cjs), so narrowing it would break unseen consumers for // no gain (Hyrum's Law). Both emit the SAME `planningPaths(cwd).debug`. debug_dir: toPosixPath(paths.debug), }; // For --raw, output a condensed key=value format if (raw) { const c = config as Record; 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, 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 shouldResync = shouldResyncStateProgress(Object.keys(patches)); // ADR-1769 Phase 6: dispatches to the STATE.md Transition Module. The // per-patch stateReplaceField loop is the pure `patchCore` in // src/state-transition.cts. readModifyWriteStateMd still owns the lock, the // #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name // delta (table-driven) that this phase adds. Field-name validation (security) // and the resync-progress decision stay in this adapter. let results: { updated: string[]; failed: string[] } = { updated: [], failed: [] }; readModifyWriteStateMd(statePath, (content) => { const result = transitionCore(content, { kind: 'patch', patches }, { clock: realClock }); results = (result.data as { updated: string[]; failed: string[] }) ?? results; return result.content; }, cwd, { resync: shouldResync }); 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 = shouldResyncStateProgress([field as string]); // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The // body-strip/reassemble single-field update is the pure `updateCore` in // src/state-transition.cts. readModifyWriteStateMd still owns the lock, the // #1230/#1264/#1695 post-sync preservation, and the no-op write guard. // Preserve curated progress for body-only updates, but allow fields that // directly project into progress.* frontmatter to rebuild after mutation. readModifyWriteStateMd(statePath, (content) => { const result = transitionCore( content, { kind: 'update', field: field as string, value: value as string }, { clock: realClock }, ); updated = (result.data as { updated: boolean } | undefined)?.updated === true; return result.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; } 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; } // ADR-1769 Phase 2: dispatches to the STATE.md Transition Module. The // ~80-line RMW callback that used to live here (plan parsing, advance vs // phase-complete branching, template-default-aware field replacement, // Current Position section mutation) is now the pure `advancePlanCore` // function in src/state-transition.cts. const intent: StateTransitionIntent = { kind: 'advancePlan' }; const deps: StateTransitionDeps = { clock: realClock, sourcePath: statePath, }; let resultData: Record | undefined; readModifyWriteStateMd(statePath, (content) => { const result = transitionCore(content, intent, deps); resultData = result.data; return result.content; }, cwd); if (!resultData || resultData['error']) { output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined); return; } if (resultData['advanced'] === false) { output(resultData, raw, 'false'); } else { output(resultData, 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) => { const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; // Find the "## Performance Metrics" section via the markdown-sectionizer // seam (ADR-2143 §7) — supersedes the prior hand-rolled section+table // regex. const metricsSection = collectSection(content, (h) => /^performance metrics$/i.test(h.text.trim())); const eol = metricsSection && /\r\n/.test(metricsSection.body) ? '\r\n' : '\n'; const lines = metricsSection ? metricsSection.body.split(/\r?\n/) : []; // Locate THIS command's OWN metrics table by its HEADER shape, using the // exact same splitTableRow/isDelimiterRow header/delimiter-shape checks // `parseMarkdownTable` uses. A live "## Performance Metrics" section // (gsd-core/templates/state.md:39-56) also carries the "By Phase" // velocity table (`| Phase | Plans | Total | Avg/Plan |`) — the prior // "first table in the section" targeting spliced every per-plan row into // THAT table instead, polluting it on EVERY plan completion // (execute-plan.md:414 calls record-metric per-plan) (#2245/#2143). // Matching the header cells to this command's own canonical // `Plan | Duration | Tasks | Files` shape (case-insensitive/trimmed) // finds the right table regardless of what else shares the section, and // deliberately does NOT require `parseMarkdownTable(...).ok` (which // additionally requires every DATA row's cell count to match the // header) — a single ragged sibling row (a hand-edited stray/extra pipe) // must not blind this scan (#2245 Blocker 2 parity with the other // Phase-4 ragged-tolerance fixes: updateTableCell / findTableStartOffset). const METRICS_HEADER = ['plan', 'duration', 'tasks', 'files']; let headerIdx = -1; for (let i = 0; i < lines.length - 1; i++) { const trimmed = lines[i].trim(); if (!trimmed.startsWith('|') || trimmed.indexOf('|', 1) === -1) continue; const delimiterLine = lines[i + 1]; if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) continue; const headerCells = splitTableRow(lines[i]); const delimiterCells = splitTableRow(delimiterLine); if (!isDelimiterRow(delimiterCells) || delimiterCells.length !== headerCells.length) continue; const normalized = headerCells.map((cell) => cell.trim().toLowerCase()); const isMetricsHeader = normalized.length === METRICS_HEADER.length && normalized.every((cell, idx) => cell === METRICS_HEADER[idx]); if (isMetricsHeader) { headerIdx = i; break; } } const hasTable = headerIdx !== -1; if (metricsSection && hasTable) { const delimiterIdx = headerIdx + 1; const prefixLines = lines.slice(0, delimiterIdx + 1); // Ragged-tolerant row scan: every consecutive `|`-prefixed line // following the delimiter counts as an existing row REGARDLESS of its // cell count matching the header — a ragged sibling row must never // blind this scan to the table's true last row (unlike // `parsedTable.value.rows.length`, which this replaces). Anchored to // the METRICS table's OWN header/delimiter (`headerIdx` above), never // the section's first table (#2245/#2143). let lastRowIdx = delimiterIdx; for (let i = delimiterIdx + 1; i < lines.length; i++) { if (!lines[i].trim().startsWith('|')) break; lastRowIdx = i; } const rowCount = lastRowIdx - delimiterIdx; _recorded = true; let newBody: string; if (rowCount > 0) { // Splice the new row immediately after the table's LAST existing data // row — every other byte of the section, INCLUDING any trailing prose // that follows the table (e.g. the default template's "**Recent // Trend:**" subsection + "*Updated after each plan completion*" // footer), is preserved verbatim. The prior implementation truncated // the section body to header+delimiter+rows+newRow, silently dropping // everything that followed the table on a live STATE.md (#2245 // Blocker 1 — a per-plan path, run after every plan execution). // `lastRowIdx` (computed above by the ragged-tolerant scan) already // equals `delimiterIdx + rowCount` by construction. const before = lines.slice(0, lastRowIdx + 1); const after = lines.slice(lastRowIdx + 1); newBody = [...before, newRow, ...after].join(eol); } else { // No existing data rows (e.g. a "None yet" placeholder line instead of // a real row) — replace the placeholder/table-body remainder with the // new row, matching the section's prior (verified) collapse-to- // first-row behavior for an otherwise-empty table. // No trailing eol here: replaceSection's `content.slice(bodyEnd)` // already supplies the newline(s) that followed the (trimEnd()-ed) // section body. newBody = prefixLines.join(eol) + eol + newRow; } return replaceSection(content, metricsSection, newBody); } if (metricsSection) { // Section EXISTS but carries no metrics table of its own — e.g. a live // STATE.md whose "## Performance Metrics" section holds only the // By-Phase velocity table (gsd-core/templates/state.md:48). Self-heal // by appending a fresh Per-Plan Metrics table to the END of the // section body — every existing byte (By-Phase table, Recent Trend, // footer) is preserved verbatim, and no second "## Performance // Metrics" heading is introduced. The section already existed, so // `created` stays false (#2245/#2143). _recorded = true; const newBody = metricsSection.body + eol + '**Per-Plan Metrics:**' + eol + eol + '| Plan | Duration | Tasks | Files |' + eol + '|------|----------|-------|-------|' + eol + newRow + eol; return replaceSection(content, metricsSection, newBody); } // Section absent (or malformed) — DWIM: auto-create canonical // ## Performance Metrics scaffold, then append the row. Matches state // begin-phase / advance-plan DWIM behavior. Header corrected to this // command's own canonical shape (`Plan | Duration | Tasks | Files`) — // the prior scaffold's `| Phase | Plan | Duration | Notes |` header // matched neither the appended row's shape nor the canonical table // above (#2245/#2143). const scaffold = [ '', '## Performance Metrics', '', '| Plan | Duration | Tasks | Files |', '|------|----------|-------|-------|', 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 = { 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; let phaseScope: Scope = SCOPE.UNREADABLE; { // #3185 (ADR-3180 Decision 1): "which phase directories belong to the // CURRENT milestone" — routed through the canonical owner instead of a // hand-rolled readdirSync + isDirInMilestone filter (which also never // excluded sentinels, unlike the owner). The owner already handles an // absent phasesDir as a real empty, so the fs.existsSync guard folds // into it. const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd }); phaseScope = scope; for (const dir of phaseDirs) { const { planCount, summaryCount } = scanPhasePlans(path.join(phasesDir, dir)); totalPlans += planCount; totalSummaries += summaryCount; } } // #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts // above are not a trustworthy answer — do not write a percentage derived // from them into STATE.md at all (A7). This is the write path, so // "withhold" means "make no edit" rather than emitting a null value. if (phaseScope !== SCOPE.COMPLETE) { // #3217 finding 3 (decided: surface a warning, not silent-only // disclosure): the JSON `reason` field alone is easy for a caller to // never read, and STATE.md's Progress field goes stale with no // user-visible signal beyond it. Mirrors the established // `[gsd-tools] WARNING:` stderr convention this file already uses // (stateReplaceFieldWithFallback above) for a comparable silent no-op. process.stderr.write( `[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` + `STATE.md's Progress field was left unchanged.\n` ); output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false'); return; } // #3233: zero plans in the current-milestone phases means there is nothing to // measure — most often the milestone was just closed and its phases archived // (.planning/phases/ empty, but scope COMPLETE — "a real empty"). clampPercent // maps 0/0 to 0%, which would clobber the shipped Progress record (e.g. // [██████████] 100% → [░░░░░░░░░░] 0%). No-op instead, mirroring the // scope-withhold above and computeProgressPercent's null-for-empty contract // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist, // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0. if (totalPlans === 0) { process.stderr.write( `[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` + `STATE.md's Progress field was left unchanged (milestone archived?).\n` ); output( { updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false', ); return; } const percent = clampPercent(totalSummaries, totalPlans); 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) => { // #2177: match against the BODY only. With /i the patterns below would // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would // eat its newline, mangling the nested block), while the body Progress: line // — which frontmatter `percent` is re-derived from on every write — stays // stale and silently reverts the update. const body = stripFrontmatter(content); const fmPrefix = content.slice(0, content.length - body.length); // Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any // descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)". const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/; const replaceValue = (value: string) => machineSegment.test(value) ? value.replace(machineSegment, progressStr) : progressStr; // Try **Progress:** bold format first, then plain Progress: format. const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i; const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im; const pattern = boldProgressPattern.test(body) ? boldProgressPattern : plainProgressPattern.test(body) ? plainProgressPattern : null; if (!pattern) return content; updated = true; return fmPrefix + body.replace(pattern, (_match, prefix: string, value: string) => `${prefix}${replaceValue(value)}`); }, 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) => { // ADR-1372 T6: find Decisions section via tokenizeHeadings; stop at level 2 or 3. // Mirrors /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i const decisionsPred = (lv: number, text: string): boolean => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text); const sectionBody = (() => { const hs = tokenizeHeadings(content); const i = hs.findIndex(h => decisionsPred(h.level, h.text)); if (i === -1) return null; const h = hs[i]; const ls = content.split('\n'); const hl = ls[h.line - 1]; const bs = h.offset + hl.length + 1; let se = content.length; for (let j = i + 1; j < hs.length; j++) { if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } } return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) }; })(); if (sectionBody !== null) { let newBody = sectionBody.body; // Remove placeholders newBody = newBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); newBody = newBody.trimEnd() + '\n' + entry + '\n'; _added = true; return content.slice(0, sectionBody.bodyStart) + newBody + content.slice(sectionBody.bodyEnd); } // 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 = { 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) => { // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3. // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i const blockersPred = (lv: number, text: string): boolean => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(text); const sectionSpan = (() => { const hs = tokenizeHeadings(content); const i = hs.findIndex(h => blockersPred(h.level, h.text)); if (i === -1) return null; const h = hs[i]; const ls = content.split('\n'); const hl = ls[h.line - 1]; const bs = h.offset + hl.length + 1; let se = content.length; for (let j = i + 1; j < hs.length; j++) { if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } } return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) }; })(); if (sectionSpan !== null) { let sectionBody = sectionSpan.body; sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; _added = true; return content.slice(0, sectionSpan.bodyStart) + sectionBody + content.slice(sectionSpan.bodyEnd); } // 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 = { added: true, blocker: blockerText }; if (created) result['created'] = true; output(result, raw, 'true'); } function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolutionOptions, raw: boolean): void { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; } const { phase, action, after, note, note_file, urgent } = options; let noteText: string | undefined = undefined; try { noteText = readTextArgOrFile(cwd, note, note_file, 'note'); } catch (err) { output({ added: false, reason: (err as Error).message }, raw, 'false'); return; } // Reject missing / empty / whitespace-only notes — an evolution entry with no // narrative is meaningless and would corrupt the section with a dangling bullet. if (!noteText || !noteText.trim()) { output({ error: 'note required' }, raw, undefined); return; } // Flatten line breaks so the entry is always a single Markdown bullet. The // dedupe + rendering contract is line-oriented; a multiline --note-file would // otherwise spill continuation lines outside the bullet and defeat dedupe. // Internal spacing (e.g. dollar columns) is preserved. const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim(); const actionText = (action && action.trim()) || 'changed'; const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : ''; const urgentText = urgent ? ' (URGENT)' : ''; const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`; let duplicate = false; let created = false; let subsectionCreated = false; // The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope // every lookup to that section's body so a `### Roadmap Evolution` heading in an // unrelated h2 section (or a fenced example) can never be matched or mutated. // The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3 // subsections stay inside the captured Accumulated Context body. // Section boundaries mirror the sibling handlers (add-decision/add-blocker): // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed, // so following sections are preserved without data loss (see the CRLF test). // // ADR-1372 T6: accPattern and subPattern migrated to tokenizeHeadings. // accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i // → stop at level 2 only (STOP_H2_ONLY) // subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i // → applied to accBody; stop at level 2 or 3 (STOP_H2_H3) readModifyWriteStateMd(statePath, (content) => { // Locate ## Accumulated Context and extract its untrimmed body span. const accHs = tokenizeHeadings(content); const accIdx = accHs.findIndex(h => h.level === 2 && /^accumulated\s+context$/i.test(h.text)); if (accIdx !== -1) { const accH = accHs[accIdx]; const contentLines = content.split('\n'); const accHL = contentLines[accH.line - 1]; const accBodyStart = accH.offset + accHL.length + 1; let accBodyEnd = content.length; for (let j = accIdx + 1; j < accHs.length; j++) { if (STOP_H2_ONLY(accHs[j].level)) { accBodyEnd = accHs[j].offset - 1; break; } } const accBody = content.slice(accBodyStart, accBodyEnd); // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only. // tokenizeHeadings is applied to accBody to scope the search. // Stop predicate mirrors (?=\n###?|$): level 2 or 3. const subHs = tokenizeHeadings(accBody); const subIdx = subHs.findIndex(h => h.level === 3 && /^roadmap\s+evolution$/i.test(h.text)); if (subIdx !== -1) { const subH = subHs[subIdx]; const accLines = accBody.split('\n'); const subHL = accLines[subH.line - 1]; const subBodyStart = subH.offset + subHL.length + 1; let subBodyEnd = accBody.length; for (let j = subIdx + 1; j < subHs.length; j++) { if (STOP_H2_H3(subHs[j].level)) { subBodyEnd = subHs[j].offset - 1; break; } } let subBody = accBody.slice(subBodyStart, subBodyEnd); // Dedupe: exact (trimmed) line already present is a no-op replay. if (subBody.split('\n').some((line) => line.trim() === entry.trim())) { duplicate = true; return content; } subBody = subBody.replace(/None yet\.?\s*\n?/gi, ''); subBody = subBody.trimEnd() + '\n' + entry + '\n'; // Splice subBody into accBody, then splice newAccBody into content. const newAccBody = accBody.slice(0, subBodyStart) + subBody + accBody.slice(subBodyEnd); return content.slice(0, accBodyStart) + newAccBody + content.slice(accBodyEnd); } // Subsection missing — append it at the end of the Accumulated Context body. subsectionCreated = true; const trimmedAcc = accBody.trimEnd(); const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`; return content.slice(0, accBodyStart) + block + content.slice(accBodyEnd); } // No `## Accumulated Context` — DWIM: create both at end of file. // Mirrors the add-decision / add-blocker auto-create behavior. created = true; subsectionCreated = true; const scaffold = [ '', '## Accumulated Context', '', '### Roadmap Evolution', '', entry, '', ].join('\n'); return content.trimEnd() + '\n' + scaffold; }, cwd); if (duplicate) { output({ added: false, reason: 'duplicate', entry }, raw, 'false'); return; } const result: Record = { added: true, entry }; if (created) result['created'] = true; if (subsectionCreated) result['subsection_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) => { // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3. // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i const hs = tokenizeHeadings(content); const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text)); if (i === -1) return content; const h = hs[i]; const ls = content.split('\n'); const hl = ls[h.line - 1]; const bs = h.offset + hl.length + 1; let se = content.length; for (let j = i + 1; j < hs.length; j++) { if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } } const sectionBody = content.slice(bs, se); 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.slice(0, bs) + newBody + content.slice(se); }, 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). // A `## Session Continuity` heading (bootstrap shape) is handled additively — // missing canonical fields are inserted while the heading and any prose are // preserved (#1101). Only append a brand-new section when NEITHER heading exists. 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. The // canonical normalized form is `## Session`; the bootstrap templates // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit // `## Session Continuity`. Treat each separately so we never append a // duplicate section alongside an existing one. const existingCanonicalSession = /^## Session[ \t]*$/im.test(content); const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content); // Track whether the chosen branch's rewrite actually matched. The detector // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF- // tolerant ($ under /m treats \r as a line terminator); the writer regexes // below must be too. If a writer regex silently fails to match (line-ending // mismatch, unexpected heading shape, ...), do NOT report success — the // caller would believe fields were persisted that were silently dropped // (#2450). The append branch always sets rewriteMatched=true (it always // mutates content). let rewriteMatched = false; if (existingCanonicalSession) { // 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. // // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while // updated.push(...) reported success — #2450. The detector regex on the // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the // asymmetry armed the bug. const canonicalReplacement = [ '## Session', '', `**Last session:** ${now}`, `**Stopped at:** ${stoppedAtValue}`, `**Resume file:** ${resumeValue}`, '', '', ].join('\n'); content = content.replace( /^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => { rewriteMatched = true; return canonicalReplacement; }, ); } else if (existingSessionContinuity) { // #1101: a `## Session Continuity` section already exists (bootstrap // shape). Previously this fell through to the append branch and created // a SECOND `## Session` block — a duplicate. Instead, insert only the // canonical fields that are still missing, right after the heading, // preserving the `## Session Continuity` heading and ALL existing lines // (e.g. prose like "Next recommended action"). Fields already updated in // place above (needs* false) are not re-inserted. A function replacement // is used so `$`-bearing caller values are inserted literally (#3454). // // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above. const linesToInsert: string[] = []; if (needsLastSession) linesToInsert.push(`**Last session:** ${now}`); if (needsStoppedAt) linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`); if (needsResumeFile) linesToInsert.push(`**Resume file:** ${resumeValue}`); if (linesToInsert.length > 0) { // Case-insensitive to match the `existingSessionContinuity` detection // above (#1101 review F3) — otherwise a lowercase heading would detect // but no-op the insert while still reporting the fields as updated. content = content.replace( /^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading: string) => { rewriteMatched = true; return heading + linesToInsert.join('\n') + '\n'; }, ); } // No `else` branch: if linesToInsert.length === 0 the outer guard at // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile // || needsLastSession)) could not have fired, so this whole block is // unreachable. Leaving `rewriteMatched = false` here is the fail-loud // posture — a future change to the outer guard or needs* computation // that makes this branch reachable will surface as a missing // updated[] entry (silent recorded:false) rather than re-arming #2450. } 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; rewriteMatched = true; } // #2450 defensive invariant: only report sessionCreated/updated when the // chosen branch's rewrite actually mutated content. Unreachable when the // writer regexes above stay in sync with the CRLF-tolerant detector — // but unreachable-defensive is the right posture for a silent-success // gate. A no-op replace here means a future line-ending or shape drift // between detector and writer; fail to record rather than claim success. // // Scope limitation (not a regression of this fix): the gate covers only // the section-rewrite block. The earlier in-place stateReplaceField // successes at :1081/:1083/:1089/:1101/:1108/:1114 push to `updated` // unconditionally — those represent fields that DID land on disk via // same-line replace (CRLF-agnostic seam), so unconditional push is // correct. The class-defect防御 here is for the INSERT path only. if (rewriteMatched) { 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 = { 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'); } } /** * Match the session section body from a STATE.md body. #1101: recognise the * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session` * block when both exist (legacy duplicate files), so the reader agrees with the * writer (which updates `## Session` first). Level-2-exact heading match * (excludes an h3 `### Session Continuity`); the exact `'session continuity'` * text match still excludes `## Session Continuity Archive` (preserving the * #2444 scoping). Migrated onto the `collectSection` seam (#2143 audit, * epic #2143): CRLF-safe — the prior hand-rolled `[ \t]*\n` regex silently * failed to match a CRLF `## Session\r\n` heading line (the `\r` broke the * `[ \t]*\n` boundary); `tokenizeHeadings` strips the trailing `\r` before * heading-text extraction, so this now matches CRLF headings too. * Returns the section body, or null. */ function matchSessionSection(body: string): string | null { const isSession = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session'; const isSessionContinuity = (h: HeadingToken): boolean => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity'; const section = collectSection(body, isSession, { levelBounded: true }) ?? collectSection(body, isSessionContinuity, { levelBounded: true }); return section ? section.body : null; } /** * Match the "Current Position" section body from a STATE.md body. #2956: this * is the Phase analogue of matchSessionSection. `Phase` canonically lives under * `## Current Position` (gsd-core/templates/state.md), so — like Stopped At / * Paused At under `## Session` — it must be extracted from THAT section, not * from the first `Phase:` / `**Phase:**` line anywhere in the body. Without the * scope, a historical `Phase:` line in an archive section silently overwrites * `current_phase` on every write, and because `current_phase` is routing input * for gsd-progress / --next the rewind routes work to the wrong phase. * * Level-flexible: the canonical template uses an h2 `## Current Position`, the * bootstrap template an h3 `### Current Position` (templates/state.md). Both * must match — mirroring how matchSessionSection recognises `## Session` and * `## Session Continuity`. Exact 'current position' text match (case-insensitive) * excludes unrelated headings. Built on the same `collectSection` seam as * matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix). * Returns the section body, or null (caller falls back to full-body search). * * The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice` * (the module that owns STATE.md field extraction) — this is a thin alias kept * for call-site stability. Two copies of this scope would be exactly the kind * of generative-fix divergence the repo's parity rule exists to prevent. */ function matchCurrentPositionSection(body: string): string | null { return stateCurrentPositionSlice(body); } /** * #2567: prevent a stale archive "Last activity:" line from overwriting a * newer frontmatter value. `stateExtractField` matches the first body * occurrence, which may be a historical line in an archive section. Unlike * Stopped At / Paused At (which canonically live in `## Session`), Last * Activity has no single canonical section — it appears in the preamble, * `## Configuration`, and `## Current Position` across STATE.md layouts, so a * section scope cannot reliably exclude archive copies. Guard the * information-losing direction instead: when the body-derived date is OLDER * than the existing frontmatter date, keep the existing value and its * description. Applied at both the write seam (syncStateFrontmatter) and the * read seam (cmdStateJson) so they agree. Date fields only — non-date values * pass through unchanged. */ function preferNewerLastActivity( existingFm: Record | null, derivedFm: Record, ): void { if (!existingFm) return; const exRaw = existingFm['last_activity']; const derRaw = derivedFm['last_activity']; if (typeof exRaw !== 'string' || typeof derRaw !== 'string') return; const exDate = exRaw.slice(0, 10); const derDate = derRaw.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate)) return; if (derDate < exDate) { derivedFm['last_activity'] = exRaw; if (existingFm['last_activity_desc'] !== undefined) { derivedFm['last_activity_desc'] = existingFm['last_activity_desc']; } } else if (derDate === exDate) { // #3052: same-date — frontmatter is authoritative for this date, so // preserve its last_activity_desc rather than letting the derived body // prose (which may be stale) overwrite it. if (existingFm['last_activity_desc'] !== undefined) { derivedFm['last_activity_desc'] = existingFm['last_activity_desc']; } } } function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this // module holds no independent prose phase-id regex. Drives #2111 — the // anchored parser returns { phase: null } for a "Milestone vX.Y complete" // body line (the old unanchored regex mined the minor-version digit, e.g. // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real // current_phase instead of clobbering it. return parsePhaseFromProse(value); } function resolveStatePhase(fm: Record, body: string): { phase: string | null; name: string | null; sources: { frontmatter: string | null; legacy_current_phase: string | null; current_position_phase: string | null; }; } { const currentPositionScope = matchCurrentPositionSection(body) ?? body; const frontmatterRaw = stateFieldValue(fm, body, 'current_phase', null).value; const legacyRaw = stateFieldValue(fm, currentPositionScope, null, 'Current Phase').value; const currentPositionRaw = stateFieldValue(fm, currentPositionScope, null, 'Phase').value; const sources = { frontmatter: parseProsePhaseField(frontmatterRaw).phase, legacy_current_phase: parseProsePhaseField(legacyRaw).phase, current_position_phase: parseProsePhaseField(currentPositionRaw).phase, }; const prosePhase = parseProsePhaseField(currentPositionRaw); return { phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase, name: stateFieldValue(fm, body, 'current_phase_name', null).value ?? stateFieldValue(fm, currentPositionScope, null, 'Current Phase Name').value ?? prosePhase.name, sources, }; } function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } { if (!value) return { date: null, description: null }; const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:\s+[—-]{1,2}\s+(.+))?$/); if (!match) return { date: value, description: null }; return { date: match[1], description: match[2]?.trim() || null, }; } 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. // Pass statePath so a truncated STATE.md is named in the #1882 diagnostic rather than // reported under a content digest — STATE.md is one of the artefacts epic #1879 is about. const fm = extractFrontmatter(content, statePath) as Record; const body = stripFrontmatter(content); // #3187: frontmatter-scalar-then-body-field precedence is owned by // state-document.cjs's `stateFieldValue` (ADR-3180 §7.7) — this function no // longer holds its own fmScalar ladder. // Extract basic fields — frontmatter keys take precedence over body // #2956: scope `Phase` extraction to ## Current Position so a historical // Phase: / **Phase:** line in an archive section cannot overwrite the current // value. Phase canonically lives in ## Current Position (templates/state.md), // so it is scopeable exactly like Stopped At under ## Session. Fall back to // full-body search only when no ## Current Position section exists, so files // with no section heading keep their current behaviour. const resolvedPhase = resolveStatePhase(fm, body); const currentPhase = resolvedPhase.phase; const currentPhaseName = resolvedPhase.name; const totalPhasesRaw = stateFieldValue(fm, body, 'total_phases', 'Total Phases').value; const currentPlan = stateFieldValue(fm, body, 'current_plan', 'Current Plan').value; const totalPlansRaw = stateFieldValue(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value; const status = stateFieldValue(fm, body, 'status', 'Status').value; const progressRaw = stateFieldValue(fm, body, 'progress', 'Progress').value; const rawLastActivity = stateFieldValue(fm, body, null, 'Last Activity').value ?? stateFieldValue(fm, body, null, 'Last activity').value; const proseLastActivity = parseProseLastActivityField(rawLastActivity); const lastActivity = stateFieldValue(fm, body, 'last_activity', null).value ?? proseLastActivity.date ?? rawLastActivity; const lastActivityDesc = stateFieldValue(fm, body, 'last_activity_desc', 'Last Activity Description').value ?? proseLastActivity.description; // #2956: Paused At canonically lives in ## Session (see the comment above // preferNewerLastActivity and the write seam in buildStateFrontmatter). The // write seam already scopes it to ## Session; this read seam must agree, so a // stale "Paused At:" in a Session Continuity Archive cannot win here either. const sessionScope = matchSessionSection(body) ?? body; const pausedAt = stateFieldValue(fm, sessionScope, 'paused_at', 'Paused At').value; // 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 — via the markdown-sectionizer/markdown-table // seams (ADR-2143 §7), cells addressed by column NAME rather than a // hand-rolled section+table regex. const decisions: Array<{ phase: string; summary: string; rationale: string }> = []; const decisionsSection = collectSection(body, (h) => /^decisions made$/i.test(h.text.trim())); const decisionsTable = decisionsSection ? parseMarkdownTable(decisionsSection.body) : null; if (decisionsTable && decisionsTable.ok) { for (const row of decisionsTable.value.rows) { const cells = decisionsTable.value.columns.map((c) => (row[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 blockersSection = collectSection(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true }); if (blockersSection) { const items = blockersSection.body.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, }; // #1101: prefer the canonical `## Session` block, falling back to the bootstrap // `## Session Continuity` heading. See matchSessionSection for the anchoring. const sessionMatch = matchSessionSection(body); if (sessionMatch !== null) { const sessionSection = sessionMatch; // 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 ────────────────────────────────────────────────── // `phaseKeyFromToken` / `phaseKeyFromDir` — the canonical key for matching a // ROADMAP phase token against an on-disk phase directory — moved to the // phase-id owner module in #2562 so every consumer derives BOTH sides of a // phase comparison from the same function (see phase-id.cts). Imported at the // top of this file; call sites below are unchanged. /** * Extract the set of retired/folded phase keys from a ROADMAP milestone scope * (#1514). A retired phase is struck through with GFM strikethrough, * e.g. `- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired`. * Such a phase keeps a `[x]` mark and often a directory but ships no completion * artifact, so it would otherwise inflate `total_phases` (the denominator) * without ever satisfying the numerator, freezing a shipped milestone below * 100%. * * Detection is scoped to the lines that canonically mark a phase retired — a * checklist entry (`- [x] …`) or a phase heading (`#### Phase …`) — and within * those, only a struck span whose SUBJECT is the phase counts: the phase * reference must sit at the start of the `~~…~~` span (after optional markdown * emphasis), as in `~~**Phase 04: Delta**~~`, `~~Phase 04~~`, or * `~~Phase PROJ-42~~`. This ignores struck PROSE that merely mentions a phase * (a goal line `~~folded into Phase 05~~`, or `~~Phase 04 was renamed~~`) and * the fold target in `~~Phase 04~~ — folded into Phase 05` (outside the span). * The phase token shape mirrors the heading counter's `[\w][\w.-]*` so numeric, * decimal, and project-code IDs are detected alike. Returns canonical keys * (see phaseKeyFromToken). */ function extractRetiredPhaseNumbers(scope: string): Set { const retired = new Set(); const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/; for (const line of scope.split(/\r?\n/)) { if (!isChecklistOrHeading.test(line)) continue; const strikeSpan = /~~([^~]*?)~~/g; let s: RegExpExecArray | null; while ((s = strikeSpan.exec(line)) !== null) { const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]); // Require a digit so struck prose like ~~Phase Overview~~ is ignored. if (phaseRef && /\d/.test(phaseRef[1])) retired.add(phaseKeyFromToken(phaseRef[1])); } } return retired; } /** * 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, storedMilestone?: string | null): Record { // #2956: scope `Phase` extraction to ## Current Position (mirrors the read // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping // below). Phase canonically lives in ## Current Position (templates/state.md); // without the scope, a historical Phase: / **Phase:** line in an archive // section overwrites current_phase here, and the next read surfaces it. Fall // back to full-body search when no ## Current Position section exists. const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent; const prosePhase = parseProsePhaseField(stateExtractField(currentPositionScope, 'Phase')); const currentPhase = stateExtractField(bodyContent, 'Current Phase') ?? prosePhase.phase; const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name') ?? prosePhase.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 rawLastActivity = stateExtractField(bodyContent, 'Last Activity') ?? stateExtractField(bodyContent, 'Last activity'); const proseLastActivity = parseProseLastActivityField(rawLastActivity); const lastActivity = proseLastActivity.date ?? rawLastActivity; const lastActivityDesc = stateExtractField(bodyContent, 'Last Activity Description') ?? proseLastActivity.description; // Bug #2444 / #2567: scope Stopped At AND Paused At extraction to the // ## Session section so historical 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. // #1101: prefer the canonical `## Session` block, falling back to the bootstrap // `## Session Continuity` heading. See matchSessionSection for the anchoring. const sessionSectionMatch = matchSessionSection(bodyContent); const sessionBodyScope = sessionSectionMatch ?? bodyContent; const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at'); // #2567: Paused At is a session field — scope it to ## Session too so a // stale "Paused At:" line in an archive section cannot overwrite the value. const pausedAt = stateExtractField(sessionBodyScope, 'Paused At'); let milestone: string | null = null; let milestoneName: string | null = null; // #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS, // independent of whether getMilestoneInfo's identity scope is COMPLETE. // Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard // — that check answers "is the ASSERTED version bounded to a versioned // ROADMAP heading", a different question from "is the identity trustworthy // enough to persist" (`milestone` above). Conflating the two regressed // #1761: when a real STATE `milestone:` value has no matching ROADMAP // heading, `info.scope` is never COMPLETE (rightly — there's no curated // name to persist), but the version was still genuinely asserted and the // bounded check must still run on it, or the guard silently no-ops and // `state json` reports a conflated whole-document total_phases/percent. let assertedMilestoneVersion: string | null = null; if (cwd) { // DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer // try/catch (roadmap-parser.cts) that already swallows every internal // failure and always returns a ScopedResult — it never throws, so this // wrapper could never be triggered. // #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6 // draws the line at the FIELD, not the scope as a whole — "a version known // but no name resolvable is TRUNCATED carrying {version, name: null} — the // version is a real answer, the name is a non-answer, and collapsing the // two is the failure this contract exists to prevent." So `milestone` // (the version) is written whenever COMPLETE or TRUNCATED — both carry a // genuine version per rule 6 — while `milestoneName` is written only on // COMPLETE, since TRUNCATED's name is by definition unresolved and must // never be fabricated. UNSCOPED/UNREADABLE have no real version either // way, so both stay null there. This mirrors cmdCommit (src/commands.cts), // which accepts COMPLETE or TRUNCATED for the same reason (the version is // real), and deliberately diverges from archivePhaseDirectories // (src/milestone.cts), which demands COMPLETE only because it uses the // value as a filesystem path component and a TRUNCATED version is not // safe to use there. const info = getMilestoneInfo(cwd); assertedMilestoneVersion = info.value ? info.value.version : null; if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) { milestone = info.value.version; } if (info.scope === SCOPE.COMPLETE && info.value) { milestoneName = info.value.name; } } 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; // #1761 read-path: set from cached.milestoneBounded inside the disk-scan // block; consumed at the percent computation to mirror the cmdStateSync guard. let milestoneUnbounded = false; // #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs // scope for the disk-scanned counts below, set from cached.phaseDirScope // when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here // — NOT a rule-4 hardcode — for the cases where no disk scan happens at all // (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight // from the pre-existing frontmatter fields parsed above, a path this phase // does not touch and which predates listMilestonePhaseDirs entirely. let diskScope: Scope = SCOPE.COMPLETE; 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) { // Read the current-milestone ROADMAP scope once: it feeds both the // heading-based phase count below and the retired/folded-phase // exclusion (#1514). Computed before the disk scan so retired phases // can be dropped from the dir set too. let roadmapScope: string | null = null; let roadmapRaw: string | null = null; let retiredPhaseNums = new Set(); try { const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw !== null) { roadmapScope = extractCurrentMilestone(roadmapRaw, cwd); retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope); } } catch { /* fall through: no roadmap scope → no retired exclusion */ } // #3017: scope the milestone filter to the STORED milestone when available, // so a state.* write doesn't auto-derive (and mis-bind) to a different // milestone's heading and clobber the stored value + progress counts. // #3185 (ADR-3180 Decision 1): "which phase directories belong to the // CURRENT (stored) milestone" — routed through the canonical owner // instead of a hand-rolled readdirSync + isDirInMilestone filter // (which also never excluded sentinels, unlike the owner). const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null }); // Bug #2445: when stale phase dirs from a prior milestone remain in // .planning/phases/ alongside new dirs with the same phase number, // de-duplicate by normalized phase number keeping the most recently // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs). const seenPhaseNums = new Map(); // normalizedNum -> dirName for (const dir of allMatchingDirs) { // #1514: a retired/folded phase keeps a directory but no completion // artifact; drop it from the disk phase set so it counts toward // neither the denominator nor the numerator (mirrors the heading // exclusion below). Project-code-aware via phaseKeyFromDir. if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue; // #3185: dedup grouping routed through the canonical phaseKeyFromDir // (src/phase-id.cts) instead of a local leading-digits regex that // diverged from extractPhaseToken/phaseKeyFromDir on // project-code-prefixed dirs (whole dirname fell through as the key, // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on // multi-segment milestone dirs. Same key surface used two lines // above for the retiredPhaseNums exclusion, so both filters agree. const key = phaseKeyFromDir(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 } = scanPhasePlans(phaseDir); diskTotalPlans += planCount; diskTotalSummaries += summaryCount; // ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are // complete" is the completion question, routed through the single // canonical owner (isPhaseComplete, src/verification.cts) — NOT // scanPhasePlans's own `completed` field, which only answers "are // all plans summarized" (a different question; see plan-scan.cts's // own comment on that field). Folding this consumer onto the raw // summaries-met flag was the exact "consolidate two of three and // leave the third" gap §7.4's forcing function rules out. if (isPhaseComplete(phaseDir).value.complete) 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; if (roadmapScope !== null) { // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\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). // Also exclude sentinel phases (0 and 999.x backlog). // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0. if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1])) continue; // #1514: retired/folded phases are struck through in the ROADMAP; // exclude them from the denominator (they can never be completed). if (retiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; roadmapPhaseCount++; } } cached = (() => { // #1761 read-path: mirror the cmdStateSync guard (#1794). When the // asserted milestone version can't be bounded to a versioned ROADMAP // heading, extractCurrentMilestone falls back to the whole document // and roadmapPhaseCount conflates sibling milestones. In that case // don't substitute the whole-doc count — fall back to the on-disk // phase-dir count only, and mark unbounded so percent is skipped // downstream (mirrors the sync write-path guard). let milestoneBounded = true; // #3216 fix (#1761 regression): use `assertedMilestoneVersion` — // the version STATE.md actually asserts — not the scope-gated // `milestone`. `milestone` is null on any non-COMPLETE identity // scope (deliberately, so a non-trustworthy identity never // persists), but a real asserted version with no matching // ROADMAP heading is EXACTLY the unbounded case this guard exists // to catch; gating on `milestone` skipped the guard entirely and // let the whole-document roadmapPhaseCount conflate sibling // milestones again. if (assertedMilestoneVersion && roadmapRaw !== null) { // #3184: routed through the single owner (roadmap-parser.cjs) // instead of a hand-rolled, unbounded-substring re-derivation — // the prior inline regex had no boundary assertion after the // version token, so `v2.0` matched inside `v2.0.1` (#2562-class // defect, design row 17). milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim()); } // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning // at all — only Phase headings) from a MILESTONED-but-unbounded one // (milestone/version headings exist but the asserted one isn't among them). // On a flat roadmap the whole-doc count is correct (no sibling milestones to // conflate); on a sectioned-but-unbounded one it conflates siblings (#1761), // so fall back to phaseDirs.length. // #3184: routed through the single owner (roadmap-parser.cjs) — // deliberately weaker than isMilestoneBoundedInRoadmap above (no // version-token requirement); see hasMilestoneSectioning's own // doc comment for why that distinction is load-bearing. const roadmapHasMilestoneSectioning = roadmapRaw !== null && hasMilestoneSectioning(roadmapRaw); const safeToUseRoadmapCount = milestoneBounded || (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning); return { totalPhases: safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length, milestoneBounded, completedPhases: diskCompletedPhases, totalPlans: diskTotalPlans, completedPlans: diskTotalSummaries, phaseDirScope, }; })(); _diskScanCache.set(cwd, cached); } totalPhases = cached.totalPhases; completedPhases = cached.completedPhases; totalPlans = cached.totalPlans; completedPlans = cached.completedPlans; milestoneUnbounded = cached.milestoneBounded === false; diskScope = cached.phaseDirScope; } /* best-effort (#2245 audit): this is a READ path building STATE.md's * display frontmatter. The real throw source is fs.readdirSync(phasesDir) * a few lines up — an inaccessible/racily-removed phases dir must not * crash `state show`; on failure this simply keeps whatever * frontmatter-derived totals/completedPhases/etc. were already set * above, a graceful degrade rather than a corrupted write (nothing is * persisted from this block). */ } 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. // #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires // a `Scope` for its own rule-4 gate. `diskScope` is the real // `listMilestonePhaseDirs` scope threaded through `_diskScanCache` // (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE // phases dir now withholds here exactly as it does at every sibling // surface, closing the cross-surface disagreement the isolated review // caught. When no disk scan ran at all (no cwd, or phasesDir absent) // `diskScope` keeps its SCOPE.COMPLETE default, preserving this // function's pre-existing behavior on that (unrelated, pre-dating // listMilestonePhaseDirs) fallback path. This call site also keeps its own // orthogonal `milestoneUnbounded` null-out below (#1761) — a different // guard (ROADMAP heading boundedness, not disk readability). let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases, diskScope); // #1761 read-path: when the milestone can't be bounded, percent would be // derived from a conflated/understated total — skip it (mirror cmdStateSync). if (milestoneUnbounded) progressPercent = null; // #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the // percentage EVERYWHERE, including this prose fallback — without the // `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%" // body line would silently defeat computeProgressPercent's rule-4 null, // re-introducing a rendered percentage on the exact scope this phase // withholds for (this is how the reviewer's UNREADABLE-phases fixture // could still surface a number even after the scope threading above). if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) { const pctMatch = progressRaw.match(/(\d+)%/); if (pctMatch) progressPercent = parseInt(pctMatch[1], 10); } const normalizedStatus = normalizeStateStatus(status, pausedAt); const fm: Record = { 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; if (lastActivityDesc) fm['last_activity_desc'] = lastActivityDesc; // #2573: stamp the commit this STATE.md was written against, so consumers can // report how far the codebase has moved since. Omitted entirely outside a git // repo — an absent field reads as "unknown", which is the honest answer and // keeps every consumer's tri-state intact (see readStateHeadFreshness). const stateHead = readGitHeadSha(cwd); if (stateHead) fm['state_head'] = stateHead; const progress: Record = {}; 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; } // ─── state_head commit provenance (#2573) ──────────────────────────────────── // // STATE.md records the commit it was written against (`state_head`); consumers // derive how many commits the codebase has moved since. This mirrors the shipped // graphify commit-staleness contract (src/graphify.cts, #3170) rather than // inventing a second vocabulary: `commits_behind` is a count, and `commit_stale` // is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable // commit), which is deliberately distinct from false ("known fresh"). // // IMPORTANT — this is a freshness PROXY, never a drift measurement. // `rev-list state_head..HEAD` counts every commit in between, including ones // that never touched anything STATE.md describes. And because `state_head` // restamps on EVERY state write, a low count means "something wrote STATE // recently", NOT "STATE's content is accurate". Consumers must word it as // approximate and must never gate on it. /** Strict hash fence before any value from disk reaches a git argument. */ const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i; /** * Resolve the project's current HEAD sha, or null when unavailable. * Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0); * a non-repo, missing git, or timeout degrades to null rather than throwing. */ /** * Does the project root carry its own git repository? * * #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST * enclosing `.git`. So the repo that answered is the project's own exactly when * the project root itself carries a `.git` entry — a directory for a normal * clone, a file for a worktree or submodule, both of which `existsSync` accepts. * If it does not, the answer necessarily came from an ancestor repo and the * stamp would assert provenance the project cannot claim. * * Deliberately a filesystem-identity check rather than comparing * `--show-toplevel` against the project root as strings. That comparison is * unreliable across platforms — macOS resolves temp dirs through * `/private/var/…`, Windows adds 8.3 short names and separator/case variance — * and an over-strict compare degrades healthy projects to "unknown", which is * the very failure this check exists to prevent, inverted. No path spelling is * involved here at all. */ function projectOwnsItsRepo(projectRoot: string): boolean { try { return fs.existsSync(path.join(projectRoot, '.git')); } catch { return false; } } function readGitHeadSha(cwd: string | undefined): string | null { if (!cwd) return null; // #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest // enclosing `.git`, and nothing pins that repo to the project. A GSD project // living inside an unrelated checkout — a dotfiles/notes repo, or the outer // workspace of a `planning.sub_repos` layout where all code commits land in // the sub-repos — would otherwise measure freshness against a repo it has no // relationship to, and report `commit_stale: false` ("known fresh") while // doing it. Unverified provenance must degrade to unknown, never to fresh. // // TWO independent conditions must hold before a stamp is trustworthy, and both // are checked below because either alone is insufficient: // 1. the project root owns a `.git` (else an ancestor repo answered), and // 2. the project is not a `sub_repos` workspace (else the repo that answers // is the outer wrapper, whose HEAD does not move when the code does). // KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports // unknown rather than measuring the children. Per-child freshness needs a // defined aggregate across N histories and is out of scope for this increment. // // `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra // subprocess on this path (the caller holds the STATE lock). let projectRoot: string; try { projectRoot = findProjectRoot(cwd); } catch { return null; // cannot prove which repo would answer → unknown } if (!projectOwnsItsRepo(projectRoot)) return null; // #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient. // In a `planning.sub_repos` workspace the outer directory can legitimately own // BOTH `.planning/` and its own repo while every code commit lands in a nested // child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per // sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD // then never advances, so `merge-base --is-ancestor` passes trivially and // `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e. // "known fresh", while the code it describes has moved arbitrarily far. // // That is a WRONG answer, not a missing one, and it is the same invariant the // ancestor-repo check above exists to protect: a freshness claim the project // cannot substantiate must degrade to unknown, never to fresh. Measuring the // children instead would mean picking one HEAD out of N unrelated histories // (or inventing an aggregate), which is a design question beyond this // increment — so this scopes to the honest tri-state and declines to answer. // Deliberately keyed on the DECLARED config rather than probing the filesystem // for nested `.git` entries: the declaration is what the workspace asserts // about itself, and a probe would spuriously fire on a vendored dependency. try { const subRepos = (loadConfig(projectRoot) as { sub_repos?: unknown }).sub_repos; if (Array.isArray(subRepos) && subRepos.length > 0) return null; } catch { return null; // cannot read the layout → cannot claim provenance → unknown } const r = execGit(['rev-parse', 'HEAD'], { cwd }); if (r.exitCode !== 0) return null; const sha = r.stdout.trim(); return STATE_HEAD_HASH_RE.test(sha) ? sha : null; } interface StateHeadFreshness { /** The recorded stamp, short form, or null when absent/malformed. */ state_head: string | null; /** Current HEAD, short form, or null outside a resolvable repo. */ current_commit: string | null; /** Commits between the stamp and HEAD; null when either end is unknown. */ commits_behind: number | null; /** Tri-state: null = unknown, false = known fresh, true = moved since. */ commit_stale: boolean | null; } /** * Derive the commit-age freshness signal from a recorded `state_head`. * * Single source of truth for the derivation — `validate.health` (W024) and * smart-entry both consume this rather than re-deriving it, so the tri-state * and the hash fence cannot drift apart between surfaces. * * Never throws: every unresolvable input degrades to nulls. */ function readStateHeadFreshness( cwd: string | undefined, stateHead: unknown, ): StateHeadFreshness { const raw = (typeof stateHead === 'string' ? stateHead : '').trim(); const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null; const head = readGitHeadSha(cwd); let commitsBehind: number | null = null; let commitStale: boolean | null = null; if (stamp && head && cwd) { // The stamp must be an ANCESTOR of HEAD before a distance means anything. // `rev-list --count A..B` exits 0 with "0" when A is not reachable from B — // which is what a `reset --hard` to an earlier commit, a rebase or squash // that drops the stamped commit, or a force-push rewriting history all // produce. Without this guard those cases report `commit_stale: false`, // i.e. "known fresh", for a codebase that was actually rewound past the // stamp — collapsing the exact unknown-vs-fresh distinction this tri-state // exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null. const ancestry = execGit(['merge-base', '--is-ancestor', stamp, head], { cwd }); if (ancestry.exitCode === 0) { const r = execGit(['rev-list', '--count', `${stamp}..${head}`], { cwd }); if (r.exitCode === 0) { const n = parseInt(r.stdout.trim(), 10); if (Number.isFinite(n)) { commitsBehind = n; // #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means // exactly what its contract says: the codebase has moved since the // stamp. Applying an advisory threshold here would make the field lie // at n < threshold, and W024 needs the true count to threshold on. // Alarm-fatigue is handled at the ALARMING surface, not the // derivation: W024 (the only user-visible consumer) fires at // STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true` // off-by-one. Smart-entry re-exports the raw tri-state as advisory // JSON and is not consumed by classify(). commitStale = n > 0; } } } } return { state_head: stamp ? stamp.slice(0, 7) : null, current_commit: head ? head.slice(0, 7) : null, commits_behind: commitsBehind, commit_stale: commitStale, }; } function syncStateFrontmatter(content: string, cwd: string | undefined, authoritativeFm?: Record): string { // Read existing frontmatter BEFORE stripping — it may contain values // that the body no longer has (e.g., Status field removed by an agent). // `cwd` already identifies the workspace this content came from, so the STATE.md path is // derivable here without widening the signature (#1882). const existingFm = extractFrontmatter( content, cwd ? planningPaths(cwd).state : undefined, ) as Record; const body = stripFrontmatter(content); // #3017: pass the stored milestone from the existing frontmatter so // buildStateFrontmatter scopes its disk scan to the correct milestone // instead of auto-deriving (and potentially mis-binding). const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null; const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone); // 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'; // #2135: widen the preserve guard. A bad derive is not always the literal // placeholder — getMilestoneInfo can return a delimiter-led fragment // ("— Active Milestone") when the roadmap regex mis-binds. Preserve the // existing curated name unless the derived value actually looks like a name: // non-empty, not the placeholder, and not punctuation-led. const derivedName = derivedFm['milestone_name']; const derivedLooksLikeName = typeof derivedName === 'string' && derivedName.length > 0 && derivedName !== MILESTONE_NAME_PLACEHOLDER && !/^[\s—–:-]/.test(derivedName); if ( !derivedLooksLikeName && 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']); } // #2202: carry forward any existing frontmatter key that the schema does not // own, so custom/unknown keys are not silently dropped on every mutating verb. // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the // preserve guards above) still win. for (const key of Object.keys(existingFm)) { if (key in derivedFm || existingFm[key] === undefined) continue; // #2573: a `source: 'free'` field is the writer's word on every write and // carries no preservation (see the FieldSource doc). When buildStateFrontmatter // omits it — `state_head` outside a git repo, per its `if (stateHead)` guard — // carrying the old value forward would re-assert provenance the file no longer // has: a stale state_head would claim STATE.md was written against a commit it // wasn't, contradicting its own ADR-1769 row. // // Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity` // ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also // `derive`, but they are body/disk-sourced and MUST still carry forward when // the writer omits them this pass — dropping `last_activity` here is silent // frontmatter data loss and would defeat #2570's staleness fix downstream. // `last_updated` and `gsd_state_version` are the only other `free` rows and are // both produced unconditionally by buildStateFrontmatter, so this loop never // reaches them; `state_head` is the sole field the skip governs. Consult the // table rather than naming fields, so the policy stays single-sourced. const classification = stateTransitionMod.getFieldClassification(key); if (classification && classification.source === 'free') continue; derivedFm[key] = existingFm[key]; } // #2567: guard the information-losing direction — a stale archive // "Last activity:" line must not overwrite a newer frontmatter value. preferNewerLastActivity(existingFm, derivedFm); // #2736: intent-first override, applied last. A transition adapter that // already holds the exact value (completePhase's next-phase display name, // beginPhase's phase name) passes it here, so the body-prose re-derivation // above — which is lossy by construction for names containing a // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs // the final word on a field the transition just resolved. The prose parser // remains the fallback for genuinely unknown prose only. if (authoritativeFm) { for (const [key, value] of Object.entries(authoritativeFm)) { if (typeof value === 'string' && value.trim().length > 0) { derivedFm[key] = value; } } } // #3257: propagate full-line frontmatter comments from the extracted source onto the // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both // skip the Symbol-keyed channel, so without this the comments would be lost here even // though parseYamlRegion/reconstructFrontmatter preserve them in isolation). propagateCommentChannel(existingFm as unknown as Frontmatter, derivedFm as unknown as Frontmatter); 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 maxWaitMs = 30000; // Deadman ceiling (audit M1) — set ABOVE maxWaitMs so a holder that reads as // VERIFIED-LIVE is NEVER stolen within the wait budget; only a crashed (dead // pid) or unparseable-body lock is stolen, and a pid-reuse holder (reads alive // but is unrelated) is recovered once age crosses this absolute ceiling rather // than blocking forever. The prior mtime-only `staleThresholdMs = 10000` gate // was BELOW maxWaitMs, so a live-but-slow holder >10 s was robbed mid-write. const deadmanCeilingMs = 60000; // Fresh-create floor (PR #1532 review, window a) — a lock with an EMPTY/unparseable // body is either mid-creation (O_EXCL create done, pid not yet written by the holder) // or a genuine orphan. While such a body is younger than this floor it is treated as // mid-creation and is NEVER stolen — stealing it at age ≈ 0 robs a holder still // writing its pid (the lost-update window capability-lock.cts's `age <= LOCK_STALE_MS` // floor closes). The create→write gap is sub-millisecond; this floor is orders of // magnitude larger yet well under maxWaitMs so a real orphan still clears within budget. // A COMPLETE dead-pid body is NOT subject to this floor — it is stolen promptly. const freshCreateFloorMs = 1000; const startedAt = clock.now(); // Shared helper: check the time budget then back off with jitter before the // next retry. Both the EEXIST contention path and the recoverable-errno path // must go through this so neither can busy-spin (#1217). const checkBudgetAndSleep = (context: string) => { if (clock.now() - startedAt >= maxWaitMs) { const e = new Error( 'acquireStateLock: ' + lockPath + ' ' + context + ' for ' + (clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)' ); (e as unknown as Record).lockBudgetExceeded = true; throw e; } const jitter = Math.floor(Math.random() * 50); clock.sleep(retryDelay + jitter); }; let _loopIteration = 0; while (true) { if (_stateLockTestHooks.onLoopIteration) _stateLockTestHooks.onLoopIteration({ iteration: _loopIteration++ }); try { const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); // Audit M9 (resource-safety): once the exclusive create SUCCEEDS, a // writeSync/closeSync failure must NOT leak the fd or strand the just-created // (now empty) lock — an orphan body self-blocks every later acquirer until a // liveness steal or the deadman. On any write/close error, guardedly close the // fd and unlink the file we created, then re-throw to the existing outer catch // (which keeps classifying recoverable vs fatal errnos — DRY). A FATAL errno // still propagates after cleanup; a RECOVERABLE one retries from a clean slate. // Mirrors capability-lock.cts:415-425. try { const injected = _consumeSimulatedWriteError(); if (injected) throw injected; // test seam: one-shot writeSync failure (M9) fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); } catch (writeErr) { try { fs.closeSync(fd); } catch { /* best-effort — fd may already be closed */ } // Best-effort unlink of the lock WE just created. Guarded so we never throw // here; if another acquirer already stole the empty lock the unlink is a // harmless ENOENT no-op (we do not double-unlink someone else's lock — the // open(O_EXCL) above guarantees we created this path this iteration). try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ } throw writeErr; // re-throw to the outer catch for recoverable/fatal classification } // 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 with the same budget + backoff as the EEXIST path so // a permanently-failing errno cannot busy-spin at 100% CPU (#1217). // See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale. if (ACQUIRE_LOCK_RETRY_ERRNOS.has((err as NodeJS.ErrnoException).code as string)) { checkBudgetAndSleep((err as NodeJS.ErrnoException).code + ' persisted'); continue; } if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates // Liveness-gated steal (audit M1) + steal-safety (PR #1532 review). The steal // decision is four-way on the lock body (#3057 B2 added the fourth): // - VERIFIED-LIVE holder (parseable pid that signals alive): NEVER stolen until // its age crosses the absolute deadman ceiling (the pid-reuse backstop) — // nuking a slow-but-live writer's lock causes lost updates (#3711 / #500/#905/ // #1230 family). // - COMPLETE DEAD pid (parseable pid, not alive): stolen PROMPTLY regardless of // age — a crashed holder left a full body. // - UNREADABLE body (I/O fault reading the file): NOT the same as empty — we // have no evidence this is a fresh create window, only that we could not read // it. Held to the SAME conservative ceiling as a verified-live holder rather // than the short fresh-create floor, so a transient read fault can never rob // an active holder the way stealing at 1s would. // - EMPTY / unparseable body (body WAS read, and holds no valid pid): liveness is // unknowable. While FRESH (age <= freshCreateFloorMs) it is a lock still // mid-creation (O_EXCL done, pid not yet written) and is NOT stolen (window a); // only once aged past the floor is it a genuine orphan and stealable. // The steal itself is an ATOMIC rename-then-recreate (only one racer can rename the // inode) guarded by an identity re-confirm, so a racer that recreates a fresh lock // in the decision→steal gap never has its replacement deleted (window b). Mirrors // capability-lock.cts:455-499. try { const stat = fs.statSync(lockPath); const ageMs = clock.now() - stat.mtimeMs; const bodyStatus = _stateLockBodyStatus(lockPath); const bodyPid = bodyStatus.kind === 'pid' ? bodyStatus.pid : null; const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid); let steal: boolean; if (holderLive) { steal = ageMs > deadmanCeilingMs; // pid-reuse backstop only } else if (bodyPid !== null) { steal = true; // complete dead pid → prompt steal } else if (bodyStatus.kind === 'unreadable') { steal = ageMs > deadmanCeilingMs; // I/O fault ≠ known-fresh — do not grant the short floor } else { steal = ageMs > freshCreateFloorMs; // empty/garbage → protect the create window } if (steal) { if (_stateLockTestHooks.beforeSteal) _stateLockTestHooks.beforeSteal({ lockPath }); // Identity re-confirm immediately before the steal: a racer that stole + // recreated a fresh lock in the decision→steal gap changes (dev, ino) and/or // the body pid → do NOT delete the replacement; re-evaluate from scratch. let confirmStat: fs.Stats; try { confirmStat = fs.statSync(lockPath); } catch { continue; // lock vanished between decision and steal — retry the create. } const sameInstance = typeof stat.dev === 'number' && typeof stat.ino === 'number' && confirmStat.dev === stat.dev && confirmStat.ino === stat.ino && _stateLockBodyPid(lockPath) === bodyPid; if (!sameInstance) { // The lock changed under us (a racer won the steal + recreated). Back off // and re-evaluate rather than deleting the racer's fresh replacement. checkBudgetAndSleep('lock changed before steal'); continue; } // Atomic steal: rename the inode aside, then remove it. Only ONE racer can // win the rename; a failed rename means another process already stole it, so // we must NOT fall through to a delete — back off and retry the create. const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++); let renamed = false; try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ } if (renamed) { try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ } // Successful steal — retry immediately to grab the just-freed lock. // Must NOT call checkBudgetAndSleep here: a throw-after-rename would // corrupt filesystem state, and the budget is already bounded on the next // iteration's EEXIST or open attempt (#1217 regression fix). continue; } // Lost the steal race (or a transient rename failure) — apply budget + backoff // so it cannot busy-spin (#1217). checkBudgetAndSleep('stale lock steal lost to racer'); continue; } } catch (err) { // Re-throw a budget-exceeded error from the steal path above unchanged — its // message already names the real cause ("lock changed before steal" / "stale // lock steal lost to racer") and double-wrapping it would replace that with the // misleading "statSync failed after EEXIST" context string (#1217 diagnostic fix). if ((err as Record)?.lockBudgetExceeded) throw err; // statSync failed — lock was likely released between our EEXIST and this // stat call. Apply budget + backoff so a persistent statSync failure // cannot busy-spin (#1217). checkBudgetAndSleep('statSync failed after EEXIST'); continue; } checkBudgetAndSleep('held by live process'); } } } function releaseStateLock(lockPath: string): void { _heldStateLocks.delete(lockPath); try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } } function withStateLock(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 { const lockPath = acquireStateLock(statePath, clock); // Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a // concurrent writer landing in the (now-closed) scan→lock window. if (_stateLockTestHooks.afterAcquire) _stateLockTestHooks.afterAcquire(lockPath); try { // Audit M8 (leaky-abstractions): the disk scan that counts PLAN/SUMMARY files // to build the frontmatter is the READ half of this read-modify-write — it must // run INSIDE the lock (mirroring readModifyWriteStateMd), not before it. Scanning // before acquireStateLock left a TOCTOU window where a concurrent writer that // committed a new PLAN/SUMMARY between our scan and our lock made writeStateMd // stamp STALE progress counts (lost update — the #500/#905/#1230 family). The // scan order is otherwise byte-for-behaviour identical for single-threaded // callers — only the concurrent-writer window closes. // // Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY // files that buildStateFrontmatter must see (#1967). if (cwd) _diskScanCache.delete(cwd); const synced = syncStateFrontmatter(content, cwd); 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): boolean { 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, statePath) as Record; // Bug #1230: delta heuristic — snapshot pre-transform body source fields so // we can detect whether THIS write changed them. syncStateFrontmatter // re-derives frontmatter status/stopped_at from the body on every write; // when the body's source field was NOT changed by the transform, the // existing frontmatter value (e.g. a hand-set 'completed') must win over // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm` // above (null when resync:true) — these are independent snapshots. // Strip frontmatter before calling stateExtractField so the YAML `status:` // key in the frontmatter block cannot shadow the body field we are tracking. const preBody = stripFrontmatter(content); const preFmSnapshot = extractFrontmatter(content, statePath) as Record; const preBodyStatus = stateExtractField(preBody, 'Status'); // Bug #1230 / Change B: scope stopped_at delta to the ## Session section, // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172). // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity // Archive prose) must not interfere with the delta comparison. const preSessionMatch = matchSessionSection(preBody); const preSessionScope = preSessionMatch ?? preBody; const preBodyStoppedAt = stateExtractField(preSessionScope, 'Stopped At') || stateExtractField(preSessionScope, 'Stopped at'); // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When // this write does NOT change that line, the curated frontmatter value must // win over syncStateFrontmatter's body re-derivation (which can harvest a // wrong parenthetical aside — #1695). Gated by the field-classification // table's preserve-always row so the rule lives in one place. const preBodyPhaseSource = stateExtractField(preBody, 'Phase'); 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 false; } let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm); // Post-transform body source fields used for the delta comparison (#1230). // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced. // Strip frontmatter so the YAML status key cannot shadow the body field we are tracking. const postBody = stripFrontmatter(modified); const postBodyStatus = stateExtractField(postBody, 'Status'); // Bug #1230 / Change B: scope stopped_at delta to the ## Session section, // consistent with the pre-transform snapshot above and buildStateFrontmatter. const postSessionMatch = matchSessionSection(postBody); const postSessionScope = postSessionMatch ?? postBody; const postBodyStoppedAt = stateExtractField(postSessionScope, 'Stopped At') || stateExtractField(postSessionScope, 'Stopped at'); // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the // current_phase_name delta comparison. const postBodyPhaseSource = stateExtractField(postBody, 'Phase'); // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync // preservation block is now the pure, table-driven `applyStatePreservation` // in the STATE.md Transition Module. progress / status / stopped_at / // current_phase_name are all governed by their FIELD_CLASSIFICATION row — // one policy source, not three drifting encodings. Behavior-identical to // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md // already claimed shipped. const postFm = extractFrontmatter(synced, statePath) as Record; const preservation = applyStatePreservation({ preFm, postFm, preFmSnapshot, resync, deriveProgressKeys: options?.deriveProgressKeys === true, preBodyStatus, postBodyStatus, preBodyStoppedAt, postBodyStoppedAt, preBodyPhaseSource, postBodyPhaseSource, }); // #2736: re-assert the intent-first values AFTER preservation. On STATE.md // layouts with no body `Phase:` line, both phase-source snapshots are null // (equal), so the #1695 restore fires and would put the stale pre-transition // name back over the authoritative one. Intent beats both the prose // re-derivation and the curated restore — the transition just resolved it. let authoritativeReasserted = false; if (options?.authoritativeFm) { for (const [key, value] of Object.entries(options.authoritativeFm)) { if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) { preservation.postFm[key] = value; authoritativeReasserted = true; } } } if (preservation.mutated || authoritativeReasserted) { const yamlStr = reconstructFrontmatter(preservation.postFm as unknown as Frontmatter); const body = stripFrontmatter(synced); synced = `---\n${yamlStr}\n---\n\n${body}`; } platformWriteSync(statePath, synced); return true; } 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, statePath) as Record; 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']); } // #2567: guard the information-losing direction — a stale archive // "Last activity:" line must not surface as the current value. Mirrors the // syncStateFrontmatter guard so the read path agrees with the write path. preferNewerLastActivity(existingFm, built); 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; } // ADR-1769 Phase 1: dispatches to the STATE.md Transition Module. The 175-line // RMW callback that used to live here (format detection + preservation policy // + section mutation + idempotency guard + resume branching) is now the pure // `transitionCore` function in src/state-transition.cts, backed by the // field-classification table. readModifyWriteStateMd still owns the lock, // #1230 post-sync preservation, and the no-op write guard. const intent: StateTransitionIntent = { kind: 'beginPhase', phaseNumber, phaseName: phaseName ?? null, planCount: planCount ?? null, }; const deps: StateTransitionDeps = { clock: realClock, sourcePath: statePath, }; // #2736: the transition holds the exact display name; without this the // post-transform sync re-derives current_phase_name from the freshly // written `Phase: N (Name) — EXECUTING` line, which truncates any name // that itself contains a parenthetical. The #1695 delta-gate preservation // still runs after the sync; the override is re-asserted after it inside // readModifyWriteStateMd for layouts with no body `Phase:` line. const rmwOptions: ReadModifyWriteOptions = { authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined, }; let updated: string[] = []; readModifyWriteStateMd(statePath, (content) => { const result = transitionCore(content, intent, deps); updated = result.updated; // #3127 resume: the core preserved the mid-flight Current Phase Name, so // the intent-first override must not fire — it would drift frontmatter // away from the preserved body value. Dropping it here is safe because // readModifyWriteStateMd consults options only after this callback returns. if (result.data?.['resumed']) { delete rmwOptions.authoritativeFm; } return result.content; }, cwd, rmwOptions); 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) ──────────────────────── /** * Find the character offset where the FIRST GFM table whose header is a * superset of `required` column names begins (order-independent; extra * columns tolerated) — the position-aware counterpart to markdown-table's * `findTableWithColumns`, used to scope `updateTableCell` (which always * operates on "the first table in its input") to the RIGHT table when an * unrelated earlier table (that doesn't itself name every required column) * may precede it in the same document. Returns `null` when no such table is * found. Never trips the table-regex fingerprint (no `[^|]` cell-capture * class) and never throws. * * Ragged-tolerant (#2245 Blocker 2): accepts the offset the moment a HEADER * line names every required column — it deliberately does NOT additionally * require `parseMarkdownTable(text.slice(m.index)).ok`, which validates every * DATA row's cell count. A ragged sibling row anywhere in the table used to * make that whole-table parse fail, so the offset came back `null` and the * caller's `updateTableCell` calls (which scope to this offset) never even * ran against an otherwise-perfectly-findable row. */ function findTableStartOffset(text: string, required: string[]): number | null { const lineRe = /^[ \t]*\|.*\|[ \t]*$/gm; let m: RegExpExecArray | null; while ((m = lineRe.exec(text)) !== null) { const trimmed = m[0].trim(); const cols = trimmed.replace(/^\|/, '').replace(/\|$/, '').split(/(? c.trim()); if (required.every((rq) => cols.includes(rq))) { return m.index; } } return null; } /** * 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 { // By Phase table — upsert the row for THIS phase FIRST. The velocity total is then // DERIVED from the table's Plans column so it stays idempotent on re-run: completing // the same phase again upserts the same row, so the column sum is stable. The previous // blind-add (prevTotal + summaryCount) re-read the cumulative total each call and // double-counted on every re-run. (#1582) // // Located by column NAME via the markdown-table seam (ADR-2143 §7) — // supersedes the prior module-level byPhaseTablePattern regex for the // existence/lookup half of this logic. const byPhaseCols = ['Phase', 'Plans', 'Total', 'Avg/Plan']; // Ragged-tolerant (#2245 Blocker 2): scope to the table's start offset // (findTableStartOffset — itself now ragged-tolerant, see above) rather // than gating existence/lookup on findTableWithColumns, which requires the // WHOLE table to parse — a ragged row for a DIFFERENT phase used to // silently no-op every phase's upsert. const tableStart = findTableStartOffset(content, byPhaseCols); if (tableStart !== null) { // Match the existing row for this phase, tolerating leading-zero padding in either // direction (#1659): canonicalize a numeric phase to its integer form so a seeded // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa. const phaseNumStr = String(phaseNum); const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr); const phaseCellRe = new RegExp(`^${canonCell}$`, 'i'); const rowMatch = (row: Record): boolean => phaseCellRe.test((row['Phase'] ?? '').trim()); const before = content.slice(0, tableStart); let tableText = content.slice(tableStart); // Ragged-tolerant existence probe: a no-op updateTableCell write on the // identifying "Phase" column (its own tolerant row scan) decides whether // this phase's row already exists, without requiring every OTHER row in // the table to also parse cleanly. let rowExists = false; const existsProbe = updateTableCell(tableText, rowMatch, 'Phase', (current) => { rowExists = true; return current; }); void existsProbe; if (rowExists) { // Update existing row — one updateTableCell call per column (Phase // itself may also change shape, e.g. "05" -> "5" per #1659). const phaseResult = updateTableCell(tableText, rowMatch, 'Phase', ` ${phaseNum} `); if (phaseResult.ok) tableText = phaseResult.value; const plansResult = updateTableCell(tableText, rowMatch, 'Plans', ` ${summaryCount} `); if (plansResult.ok) tableText = plansResult.value; const totalResult = updateTableCell(tableText, rowMatch, 'Total', ' - '); if (totalResult.ok) tableText = totalResult.value; const avgResult = updateTableCell(tableText, rowMatch, 'Avg/Plan', ' - '); if (avgResult.ok) tableText = avgResult.value; content = before + tableText; } else { // Row doesn't exist — INSERT a new row. Row insertion (unlike a cell // update) is outside updateTableCell's scope (ADR-2143 §7 Phase 4); // `insertTableRow` (markdown-table.cjs) is its name-addressed, // header-order-agnostic sibling (#2245 audit: this used to locate the // table via `byPhaseTablePattern`, a canonical-column-ORDER-only regex, // and build the row as a hardcoded positional literal — so a reordered/ // superset By-Phase header, already tolerated above by // findTableStartOffset and read by-NAME in the update/sum halves, // silently inserted NOTHING). // // Drop a lone all-placeholder row first (e.g. the freshly-scaffolded // "| - | - | - | - |" seed row) — same convention the prior // canonical-order path used, generalized to any column order/count: // a row whose every PRESENT cell is "-" is the placeholder. const placeholderRow = (row: Record): boolean => Object.values(row).every((cell) => cell.trim() === '-'); const withoutPlaceholder = deleteTableRow(tableText, placeholderRow); if (withoutPlaceholder.ok) tableText = withoutPlaceholder.value; // Map the By-Phase values onto the table's ACTUAL header columns by // NAME — an unrecognized column (a superset header) falls back to "-", // insertTableRow's default. const valueFor = (col: string): string | undefined => { if (col === 'Phase') return String(phaseNum); if (col === 'Plans') return String(summaryCount); if (col === 'Total' || col === 'Avg/Plan') return '-'; return undefined; }; const insertResult = insertTableRow(tableText, valueFor); if (insertResult.ok) tableText = insertResult.value; content = before + tableText; } } // Velocity: Total plans completed — DERIVED as the sum of the By-Phase Plans column // across all data rows. Idempotent by construction (re-running phase complete upserts // the same row → same sum) and self-healing (a hand-edited inflated total is corrected // to the true sum on the next completion). When the By-Phase table is absent, leave the // velocity total unchanged rather than guess. (#1582) // // Ragged-tolerant AND name-addressed (#2245 audit): each data row is split via // `splitTableRow` and its "Plans" cell located by the HEADER's own column // order (not a fixed ordinal), so a reordered/superset By-Phase header is // summed correctly instead of silently reading the wrong cell. A row that's // too short to physically contain the "Plans" column is skipped, not // treated as an error — mirrors updateTableCell's ragged-row tolerance // (a hand-edited/ragged row for one phase must not blank out the derived // total for every phase). Still scoped via findTableStartOffset so the RIGHT // table is summed when an earlier unrelated table also has a "Phase" // column (#2012). if (/Total plans completed:\s*(\d+|\[N\])/.test(content)) { const sumTableStart = findTableStartOffset(content, byPhaseCols); if (sumTableStart !== null) { const tableLines = content.slice(sumTableStart).split(/\r?\n/); const headerCells = splitTableRow(tableLines[0] ?? ''); const plansIdx = headerCells.indexOf('Plans'); let sum = 0; if (plansIdx !== -1) { // The delimiter row is skipped by NAME (isDelimiterRow), not by a // hardcoded "always line index 1" assumption, so this stays // self-consistent with the ragged-tolerant read below. const delimiterCells = splitTableRow(tableLines[1] ?? ''); const dataStart = isDelimiterRow(delimiterCells) ? 2 : 1; for (const row of tableLines.slice(dataStart)) { if (!row.trim().startsWith('|')) break; const cells = splitTableRow(row); if (plansIdx < cells.length && /^\d+$/.test(cells[plansIdx])) { sum += parseInt(cells[plansIdx], 10); } } } content = content.replace( /Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${sum}`, ); } } 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; } // ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The RMW // callback that lived here (body strip/reassemble, template-aware Status + // Last Activity, Total Plans in Phase, Last Activity Description, Current // Position section update) is the pure `plannedPhaseCore` in // src/state-transition.cts, backed by the field-classification table. // resync:false is preserved: plan-phase must NOT re-derive milestone-wide // progress.* from a half-planned disk snapshot (#500 RC1). readModifyWriteStateMd // still owns the lock, the #1230 preservation, and the no-op write guard. const intent: StateTransitionIntent = { kind: 'plannedPhase', phaseNumber, planCount: planCount ?? null, }; const deps: StateTransitionDeps = { clock: realClock, sourcePath: statePath, }; let updated: string[] = []; readModifyWriteStateMd(statePath, (content) => { const result = transitionCore(content, intent, deps); updated = result.updated; return result.content; }, cwd, { resync: false, deriveProgressKeys: true }); const result = updated.length === 0 ? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' } : { updated, phase: phaseNumber, plan_count: planCount }; output(result, 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 )' }, raw, undefined); return; } const resolvedName = (name && String(name).trim()) || 'milestone'; const statePath = planningPaths(cwd).state; // ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The reset // policy (frontmatter rebuild + Current Position body reset) is the pure // `milestoneSwitchCore` in src/state-transition.cts. acquireStateLock + // platformWriteSync are retained (NOT readModifyWriteStateMd) because // milestoneSwitch rebuilds frontmatter directly and must not run the // steady-state syncStateFrontmatter post-sync. const intent: StateTransitionIntent = { kind: 'milestoneSwitch', version, name: resolvedName }; const deps: StateTransitionDeps = { clock: realClock, sourcePath: statePath }; const lockPath = acquireStateLock(statePath); try { const content = platformReadSync(statePath) || ''; const result = transitionCore(content, intent, deps); platformWriteSync(statePath, result.content); output( { switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true', ); } finally { releaseStateLock(lockPath); } } /** * Gate 1: Validate STATE.md against filesystem. * Returns { valid, warnings, drift, scope } JSON. * * #3187 (ADR-3180 §7.7, Decisions 2-4): two defects fixed here. * * (1) #3162 THE HEADLINE. Every warning this function can emit used to be * gated behind `if (currentPhase && fs.existsSync(phasesDir))`, and * `currentPhase` came from a body-only `stateExtractField(content, 'Current * Phase')` call with no frontmatter fallback. A STATE.md whose phase lives * ONLY in frontmatter therefore resolved `currentPhase` to `null`, the whole * drift block was skipped, and the function returned * `{valid:true, warnings:[], drift:{}}` — "could not look" was * output-identical to "looked, all clean." Current Phase / Status / Total * Plans in Phase now route through `stateFieldValue` (the single owner of the * #1760 frontmatter-then-body fallback chain), so the frontmatter tier is * actually consulted. * * (2) #1255 FRONTMATTER SHADOWING. The old code passed UNSTRIPPED `content` * to the extractor. `stateExtractField`'s plain-format branch is * `^Field:` with the `i` flag, so a frontmatter `status:` key matched the * pattern for the body field `Status` and won, because the frontmatter block * precedes the body. Parsed once now — `extractFrontmatter` + * `stripFrontmatter` — and `fm`/`body` are handed to the chain owner, exactly * as `advancePlanCore`/`beginPhaseCore`/`completePhaseCore`/ * `readModifyWriteStateMd` already guard against this class of defect. * * `scope` (ADR-3180 Decision 2) reports whether the derivation actually ran: * - `COMPLETE` — the phase-vs-disk derivation ran over usable input, * including when it legitimately finds no VERIFICATION.md / no matching * phase directory (a real answer, not a non-answer). * - `UNSCOPED` — Current Phase could not be resolved by ANY chain step (no * frontmatter scalar, no body field), so the drift derivation had no * phase to scope its disk lookup to and could not run at all. Reporting * this as COMPLETE would recreate the #3162 collapse this phase closes, * one layer out. * - `UNREADABLE` — the frontmatter parse or the phases-dir scan itself * could not be consulted (an existing `catch` block used to swallow this * silently; the degrade stays, but is now visible). * * ⛔ Rejected (ADR-3180 §7.7 Rejected #2): a non-`COMPLETE` scope is never * routed to `valid:false`. `valid` keeps meaning "no drift warnings were * found"; `scope` says whether the derivation could actually run. A caller * branches on both — folding them into one boolean recreates the exact * collapse this epic removes, in the opposite direction (a legacy STATE.md * with no resolvable phase is a supported degrade, not an invalid document). */ /** * #1255/#3187: parse frontmatter and strip it from the body ONCE, shared by * `cmdStateValidate` and `cmdStateCompletePhase` so both consult the identical * fm/body precedence and degrade identically when the frontmatter half of the * chain cannot be consulted. Extracted (code-review finding, epic #3180): the * two call sites previously carried a byte-identical try/catch, comments * included — an epic whose own thesis is "one canonical owner per * derivation" must not ship a duplicated derivation in its own diff. * * Returns `scope: SCOPE.COMPLETE` unless the frontmatter parse itself threw, * in which case `fm` degrades to `{}` and `scope` becomes `SCOPE.UNREADABLE` * — callers that mutate `scope` further (e.g. `cmdStateValidate`'s later * UNSCOPED/disk-scan degrades) start from this returned value rather than a * fresh `SCOPE.COMPLETE`. */ function readStateFrontmatterScoped(content: string, statePath: string): { fm: Record; body: string; scope: planningScopeMod.Scope } { let fm: Record; let scope: planningScopeMod.Scope = SCOPE.COMPLETE; try { fm = extractFrontmatter(content, statePath); } catch { // extractFrontmatter is documented never to throw, but this mirrors the // defensive try/catch already used around it elsewhere in this file // (e.g. spliceFrontmatter) — a parse hiccup here means the frontmatter // half of the chain could not be consulted; degrade visibly. fm = {}; scope = SCOPE.UNREADABLE; } const body = stripFrontmatter(content); return { fm, body, scope }; } 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'); // #2701: fail loud on NUL/binary corruption before drift checks. A corrupt // STATE.md otherwise validates as clean and is silently skipped by recursive // searchers downstream, reading as "absent" rather than "corrupt." const encErr = textEncodingError(content, 'STATE.md'); if (encErr) { output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined); return; } const warnings: string[] = []; const drift: Record = {}; // #1255/#3187: parse frontmatter and strip it from the body ONCE, so the // chain owner sees the same fm/body precedence every other migrated call // site sees. Pass statePath so a truncated STATE.md is named in the #1882 // diagnostic rather than reported under a content digest. const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath); const scope: planningScopeMod.Scope = initialScope; const status = stateFieldValue(fm, body, 'status', 'Status').value || ''; const resolvedPhase = resolveStatePhase(fm, body); const currentPhase = resolvedPhase.phase; const totalPlansRaw = stateFieldValue(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value; const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; const phasesDir = planningPaths(cwd).phases; if (currentPhase === null) { warnings.push('Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value'); drift['phase_reference'] = { reason: 'unresolved', selected: null, sources: resolvedPhase.sources }; output({ valid: false, warnings, drift, scope }, raw, undefined); return; } const selectedPhaseKey = phaseKeyFromToken(currentPhase); if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) { warnings.push(`Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`); drift['phase_reference'] = { reason: 'conflict', selected: currentPhase, sources: resolvedPhase.sources }; } if (!fs.existsSync(phasesDir)) { warnings.push(`Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`); drift['phase_directory'] = { reason: 'missing_root', selected: currentPhase }; output({ valid: false, warnings, drift, scope }, raw, undefined); return; } let phaseDirPath: string; try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey); if (!phaseDir) { warnings.push(`Cannot validate phase drift: no phase directory matches phase ${currentPhase}`); drift['phase_directory'] = { reason: 'not_found', selected: currentPhase }; output({ valid: false, warnings, drift, scope }, raw, undefined); return; } phaseDirPath = path.join(phasesDir, phaseDir.name); } catch { warnings.push(`Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`); drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase }; output({ valid: false, warnings, drift, scope }, raw, undefined); return; } try { const scan = scanPhasePlans(phaseDirPath); if (scan.scope !== SCOPE.COMPLETE) { throw new Error('phase plan scan is incomplete'); } const { planCount: diskPlans, summaryCount: diskSummaries } = scan; // 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 { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic * warnings scan across N VERIFICATION.md files — one unreadable file * (permission/race) must not abort the scan of the rest; it's simply * excluded from drift detection. Does not degrade `scope` — the other * N-1 files were consulted fine. */ } } // 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 { warnings.push(`Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`); drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase }; } const valid = warnings.length === 0; output({ valid, warnings, drift, scope }, 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 phasesDir = planningPaths(cwd).phases; if (!fs.existsSync(phasesDir)) { output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined); return; } // #1514: read the current-milestone ROADMAP scope once so retired/folded // phases are excluded from BOTH the disk scan and the heading count here, // exactly as buildStateFrontmatter does — otherwise `state sync --verify` // would keep re-deriving the inflated denominator and report "no drift". let syncRoadmapScope: string | null = null; let syncRoadmapRaw: string | null = null; let syncRetiredPhaseNums = new Set(); try { const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); if (roadmapRaw !== null) { syncRoadmapRaw = roadmapRaw; syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd); syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope); } } catch { /* fall through: no roadmap scope → no retired exclusion */ } // Scan all phases let entries: string[]; try { entries = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(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 } = scanPhasePlans(dirPath); totalDiskPlans += plans; totalDiskSummaries += summaries; // ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single // canonical owner (isPhaseComplete), not scanPhasePlans's own `completed` // field ("are all plans summarized?" — a different question). This is the // same fix buildStateFrontmatter got above; cmdStateSync (`state sync`) // was a second, independent consumer of the same raw field the initial // migration missed — without it, `state sync` and `state json` disagreed // on completed_phases for the identical disk state. if (isPhaseComplete(dirPath).value.complete) diskCompletedPhases++; // Track the highest phase with incomplete plans (or any plans) const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, '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). // DEAD catch removed (#2245 audit): every operation in this block is a regex // exec/test over an already-read string plus pure Set/Math ops — none of // which can throw — so the try/catch could never be triggered. let syncTotalPhases: number | null = null; let roadmapPhaseCount = 0; if (syncRoadmapScope !== null) { // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== 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])) continue; // #1514: retired/folded phases are struck through; exclude from total. if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; roadmapPhaseCount++; } } if (roadmapPhaseCount > 0) { syncTotalPhases = Math.max(entries.length, roadmapPhaseCount); } else { syncTotalPhases = entries.length; } // ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last // Activity) are the pure `syncCore` in src/state-transition.cts. // #1761: when a milestone version is set in frontmatter but the ROADMAP has no // versioned heading for it, the milestone cannot be bounded to a versioned phase // set — leave Progress untouched (percent=null) rather than silently writing // fallback-derived wrong values. Projects without a milestone version (the common // sync-test shape) are unaffected: the gate only fires when a version is asserted. const fmVersion = (extractFrontmatter(content, statePath) as Record).milestone; const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null; let milestoneBounded = true; if (versionStr !== null && syncRoadmapRaw !== null) { // #3184: routed through the single owner (roadmap-parser.cjs) instead of // a hand-rolled, unbounded-substring re-derivation — see the identical // fix in buildStateFrontmatter above. milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr); } let percent: number | null = null; if (!milestoneBounded) { changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`); } else { // #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed // `entries` (the raw fs.readdirSync listing above) was "never routed // through listMilestonePhaseDirs, so there is no real Scope to pass" — // that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope` // already parsed above (~3104) is precisely what // `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives // from `cwd` to produce a real `Scope` — the identical shape already // threaded through `buildStateFrontmatter`'s `diskScope` above. Calling // it here (discarding `.value`, which duplicates `entries`'s own // retired-phase-filtered listing) gets the real scope without changing // the disk-scan totals computed above. const syncScope: Scope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope; if (syncScope !== SCOPE.COMPLETE) { changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`); } else { const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope); percent = p !== null ? p : 0; } } const syncResult = transitionCore( modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: realClock }, ); modified = syncResult.content; const coreChanges = (syncResult.data as { changes?: string[] } | undefined)?.changes ?? []; changes.push(...coreChanges); 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, 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; // Resolve the current phase via the same canonical chain buildStateFrontmatter // uses (frontmatter `current_phase` → `Current Phase` field → prose `Phase: X // of Y`), so prune engages on template-conformant STATE.md instead of bailing // "Only 0 phases" (#1760). // #1776: scope ONLY the prose `Phase:` term to the canonical `## Current // Position` section. Over the whole body, `stateExtractField`'s pipe-table // fallback matches any `| Phase | N |` row (e.g. a historical verification // table), resolving a stale phase and computing a wrong cutoff. Frontmatter and // the explicit `Current Phase` field are unambiguous, so they stay document-wide; // the shared extractor is not narrowed for any other caller. const rawState = fs.readFileSync(statePath, 'utf-8'); const fm = extractFrontmatter(rawState, statePath) as Record; const body = stripFrontmatter(rawState); // #3187: frontmatter-scalar-then-body-field precedence is owned by // state-document.cjs's `stateFieldValue` (ADR-3180 §7.7). This comment // previously claimed to mirror `buildStateFrontmatter`'s fmScalar — that // attribution was stale: buildStateFrontmatter (state.cts:1620) reads body // only and never consults frontmatter at all; this actually mirrored // cmdStateSnapshot's now-removed closure instead. const positionSection = sliceCurrentPositionSection(body); const prosePhase = positionSection !== null ? parseProsePhaseField(stateFieldValue(fm, positionSection, null, 'Phase').value).phase : null; const currentPhaseRaw = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase; const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0; const cutoff = currentPhase - keepRecent; if (cutoff <= 0) { emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false'); return; } const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md'); const archived: PrunedSection[] = []; // ADR-1769 Phase 7: the section-pruning is the pure `pruneCore` in // src/state-transition.cts (byte-identical tokenizeHeadings section splicing). // This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase` // fallback above), dry-run, and STATE-ARCHIVE.md writes. const runPruneCore = (content: string): { newContent: string; archivedSections: PrunedSection[] } => { const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: realClock }); return { newContent: result.content, archivedSections: ((result.data as { archivedSections?: PrunedSection[] } | undefined)?.archivedSections) ?? [], }; }; if (dryRun) { // Dry-run: compute what would be pruned without writing anything const content = fs.readFileSync(statePath, 'utf-8'); const result = runPruneCore(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 = runPruneCore(content); archived.push(...result.archivedSections); return result.newContent; }, cwd); // Write archived entries to STATE-ARCHIVE.md if (archived.length > 0) { const timestamp = realClock.localToday(); 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'); } /** * Rebuild STATE.md body structure from canonical sources (ADR-1817). * * Implements the `gsd state rebuild` subcommand (issue #1817 Phase 2, #1826). * Wires the pure `rebuildCore` transition (Phase 1, #1827) to the CLI: * - Locks via `readModifyWriteStateMd` (real path) or reads-only (dry-run). * - Wires `phaseInventoryProvider` to a real `.planning/phases/` disk scan. * - `--dry-run`: computes the rebuild, emits a structured diff, writes nothing. * - `--verbose`: emits the audit-log entries to stderr (in addition to the * `## Rebuild Log` section that `rebuildCore` already appends to STATE.md). * * Per ADR-1817 §5 this is the heavy/manual counterpart to the lightweight, * auto-triggered `state sync` (3 frontmatter fields). The two compose * non-overlappingly. */ function cmdStateRebuild(cwd: string, options: StateRebuildOptions, raw: boolean): void { const silent = !!options.silent; const emit = silent ? () => {} : (result: Record, 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 dryRun = !!options.dryRun; const verbose = !!options.verbose; // Wire phaseInventoryProvider to a real `.planning/phases/` disk scan. This // is the same canonical source `buildStateFrontmatter` consults; the Leaky- // Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core // testable without this dep — here we provide it. // // #3057 B1: a missing `.planning/phases/` directory is genuinely "nothing // to reconcile" (`ok:true, phases: []`) — but a `readdirSync`/`statSync` // THROW on a directory that DOES exist (permission fault, corrupted // mount, etc.) is a real scan failure (`ok:false`). The old implementation // returned `null` for both, so `state rebuild` could report success while // by-phase-table reconciliation silently never ran. Per-entry stat // failures (an individual phase dir vanishing mid-scan) still `continue` // past that one entry — that is not a whole-scan failure. const phaseInventoryProvider = (): PhaseInventoryResult => { try { const phasesDir = path.join(planningPaths(cwd).planning, 'phases'); if (!fs.existsSync(phasesDir) || !fs.statSync(phasesDir).isDirectory()) return { ok: true, phases: [] }; // #3185: deliberately NOT listMilestonePhaseDirs. `state rebuild` is a // RECONCILIATION pass against ground truth -- it must see every phase // directory on disk so an orphan STATE.md row for a phase that no longer // exists (or sits outside the current window) is dropped. Scoping this // would make the rebuild silently preserve stale rows. const entries = fs.readdirSync(phasesDir); const records: PhaseInventoryRecord[] = []; for (const entry of entries) { const full = path.join(phasesDir, entry); let stat: fs.Stats; try { stat = fs.statSync(full); } catch { continue; } if (!stat.isDirectory()) continue; // Directory-name convention: `-` (e.g. `03-test-phase`). const m = entry.match(/^(\d+)-(.+)$/); if (!m) continue; // #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source // planCount/summaryCount from the single owner (scanPhasePlans) // instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync // filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A // non-COMPLETE scope (TRUNCATED: nested plans/ unreadable; // UNREADABLE: `full` itself unreadable) is not a trustworthy count — // throw so it surfaces via the outer catch as a real scan failure // (`ok:false`), mirroring the #3057 B1 contract documented above for // the sibling `fs.readdirSync(phasesDir)` failure mode, rather than // silently reporting an undercount. const scan = scanPhasePlans(full); if (scan.scope !== SCOPE.COMPLETE) { throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`); } const { planCount, summaryCount } = scan; records.push({ number: m[1], name: m[2], planCount, summaryCount }); } return { ok: true, phases: records }; } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : String(err) }; } }; const deps: StateTransitionDeps = { clock: realClock, phaseInventoryProvider, // Without this, `state rebuild --dry-run` reported a truncated STATE.md anonymously: the // write path is named only because readModifyWriteStateMd parses with the path first, and // the dry-run branch reads the file directly and never does. Dry-run is the read-only mode // an operator reaches for first when they suspect corruption, so it is the one that most // needs to name the file (#1882). sourcePath: statePath, }; const runRebuild = (content: string) => transitionCore(content, { kind: 'rebuild' }, deps); const emitVerboseLog = (log: unknown): void => { if (!verbose || !Array.isArray(log)) return; for (const entry of log) { // Treat user-data as data-only (ADR-1577 untrusted-input-boundary). process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`); } }; // #3057 B1: distinguish "nothing to rebuild" from "the phase-inventory // disk scan failed, so by-phase-table reconciliation could not run" — both // used to collapse to the same `mutated:false` / "Nothing to rebuild" note. type RebuildData = { log?: unknown[]; mutated?: boolean; phase_inventory_scan_failed?: boolean; phase_inventory_scan_reason?: string; }; const scanFailureNote = (reason: string | undefined): string => 'Nothing rebuilt: the phase-inventory disk scan failed, so by-phase-table reconciliation did not run' + (reason ? ` (${reason})` : ''); if (dryRun) { const content = fs.readFileSync(statePath, 'utf-8'); const result = runRebuild(content); const data = (result.data ?? {}) as RebuildData; emitVerboseLog(data.log); const mutated = data.mutated === true; const scanFailed = data.phase_inventory_scan_failed === true; emit({ rebuilt: false, dry_run: true, mutations: Array.isArray(data.log) ? data.log.length : 0, mutated, phase_inventory_scan_failed: scanFailed, phase_inventory_scan_reason: scanFailed ? data.phase_inventory_scan_reason : undefined, note: mutated ? 'Run without --dry-run to apply changes' : scanFailed ? scanFailureNote(data.phase_inventory_scan_reason) : 'Nothing to rebuild', }, raw, mutated ? 'true' : 'false'); return; } // Real path: lock + RMW via the existing seam. The rebuild log is captured // so we can emit it to stderr under --verbose (the section is also written // to STATE.md by rebuildCore itself, per ADR-1817 §3). let capturedLog: unknown[] = []; let capturedMutated = false; let capturedScanFailed = false; let capturedScanReason: string | undefined; readModifyWriteStateMd(statePath, (content: string) => { const result = runRebuild(content); const data = (result.data ?? {}) as RebuildData; capturedLog = Array.isArray(data.log) ? data.log : []; capturedMutated = data.mutated === true; capturedScanFailed = data.phase_inventory_scan_failed === true; capturedScanReason = data.phase_inventory_scan_reason; return result.content; }, cwd); emitVerboseLog(capturedLog); emit({ rebuilt: capturedMutated, mutations: capturedLog.length, phase_inventory_scan_failed: capturedScanFailed, phase_inventory_scan_reason: capturedScanFailed ? capturedScanReason : undefined, note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : capturedScanFailed ? scanFailureNote(capturedScanReason) : 'Nothing to rebuild', }, raw, capturedMutated ? '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(fm: Record, body: string, overridePhase: string | undefined): string | null { // #3187: route through the single #1760 fallback-chain owner (fm scalar // then body field) instead of two raw stateExtractField calls on // frontmatter-blind content — a STATE.md whose phase lives only in // frontmatter no longer resolves to null here. `Phase` (the historical // second-choice field name) has no frontmatter counterpart, so its fmKey // is null — same shape as cmdStateSnapshot's `stateFieldValue(fm, // currentPositionScope, null, 'Phase')` fallback. const candidate = overridePhase || stateFieldValue(fm, body, 'current_phase', 'Current Phase').value || stateFieldValue(fm, body, null, 'Phase').value || ''; // #2125: parse via the canonical anchored parser so a narrative `Phase:` // body line (e.g. "Milestone v0.5 complete") does not mine a bogus token — // the old unanchored regex yielded "0.5" and rewrote STATE.md as // "Phase 0.5 complete". A canonical token at the start of the value // (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone // closure line yields null, so the caller's "unable to resolve" guard fires. return parsePhaseFromProse(candidate).phase; } 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'); // #1255/#3187: parse frontmatter and strip it from the body ONCE, mirroring // cmdStateValidate/cmdStateSnapshot, so resolvePhaseIdForCompletePhase and // the idempotency guard below consult the identical fm/body precedence — // the two sites cannot drift onto different chains, extending the #2125 // "same canonical parser" guarantee one layer earlier. const { fm, body, scope } = readStateFrontmatterScoped(content, statePath); // #3187 Postel/visibility (design doc's sharpest case): this whole handler // is the DESTRUCTIVE path the #3489 idempotency guard below protects — it // decides whether a re-run of `state complete-phase --phase N` is allowed // to roll STATE.md back to N's moment-of-completion. If the frontmatter // half of the chain could not be consulted (`scope` UNREADABLE), // `existingCurrentPhase` below could read as null even though the // project's true current phase lives only in that unreadable frontmatter — // silently treating a non-COMPLETE scope as "not complete" would let the // guard's `existingCurrentPhase &&` check fail OPEN and re-run an // already-completed phase. Refuse outright instead of guessing; this // applies even when `--phase` is explicit, because the guard's job is to // protect against exactly that already-completed-phase case regardless of // how the target phase was named. if (scope !== SCOPE.COMPLETE) { output( { error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined, ); return; } const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase); if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) { output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase ' }, 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 ` in that situation previously rolled // STATE.md back to '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 = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value || ''; // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two // sites cannot diverge on the token they extract. const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase; if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) { output( { updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false', ); return; } const today = realClock.localToday(); const updated: string[] = []; readModifyWriteStateMd(statePath, (content) => { const currentPhase = resolvedPhase; // Bug #1255: operate on body only so the YAML frontmatter `status:` key // cannot shadow the body Status field (pipe-table or inline). const existingFm = extractFrontmatter(content, statePath) as Record; const hasFrontmatter = Object.keys(existingFm).length > 0; let body = stripFrontmatter(content); const reassemble = (b: string) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b; // Update Status field (body only — #1255) const statusValue = `Phase ${currentPhase} complete`; let result = stateReplaceField(body, 'Status', statusValue); if (result) { body = result; updated.push('Status'); } // Update Last Activity date result = stateReplaceField(body, 'Last Activity', today); if (result) { body = result; updated.push('Last Activity'); } // Update Last Activity Description const activityDesc = `Phase ${currentPhase} marked complete`; result = stateReplaceField(body, 'Last Activity Description', activityDesc); if (result) { body = result; updated.push('Last Activity Description'); } // Update ## Current Position section // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2. // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i { const cpHs = tokenizeHeadings(body); const cpIdx = cpHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); if (cpIdx !== -1) { const cpH = cpHs[cpIdx]; const cpBodyLines = body.split('\n'); const cpHL = cpBodyLines[cpH.line - 1]; const cpBodyStart = cpH.offset + cpHL.length + 1; let cpBodyEnd = body.length; for (let j = cpIdx + 1; j < cpHs.length; j++) { if (STOP_H2_PLUS(cpHs[j].level)) { cpBodyEnd = cpHs[j].offset - 1; break; } } let posBody = body.slice(cpBodyStart, cpBodyEnd); // Update Phase line to show COMPLETE const newPhase = `Phase: ${currentPhase} — COMPLETE`; if (/^Phase:/m.test(posBody)) { posBody = posBody.replace(/^Phase:.*$/m, newPhase); } else { // Pipe-table format in Current Position (#1255) // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label. const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`); if (replaced !== null) posBody = replaced; } // Update Status line if present const newStatus = `Status: Phase ${currentPhase} complete`; if (/^Status:/m.test(posBody)) { posBody = posBody.replace(/^Status:.*$/m, newStatus); } else { // Pipe-table format in Current Position (#1255) const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`); if (replaced !== null) posBody = replaced; } // 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); } else { // Pipe-table format in Current Position (#1255) // Value must match the inline branch (date + narrative), not bare date. const activityValue = `${today} — Phase ${currentPhase} marked complete`; const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) ?? stateReplaceField(posBody, 'Last activity', activityValue); if (replaced !== null) posBody = replaced; } body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd); updated.push('Current Position'); } } return reassemble(body); }, cwd); output( { updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false', ); } export = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, acquireStateLock, releaseStateLock, writeStateMd, readModifyWriteStateMd, syncStateFrontmatter, readStateHeadFreshness, withStateLock, updatePerformanceMetricsSection, cmdStateLoad, cmdStateGet, cmdStatePatch, cmdStateUpdate, cmdStateAdvancePlan, cmdStateRecordMetric, cmdStateUpdateProgress, cmdStateAddDecision, cmdStateAddBlocker, cmdStateAddRoadmapEvolution, cmdStateResolveBlocker, cmdStateRecordSession, cmdStateSnapshot, cmdStateJson, cmdStateBeginPhase, cmdStatePlannedPhase, cmdStateCompletePhase, cmdStateValidate, cmdStateSync, cmdStatePrune, cmdStateRebuild, cmdStateMilestoneSwitch, cmdSignalWaiting, cmdSignalResume, // Test seam (#1514): the pure retired/folded-phase parser, exposed so its // strikethrough-detection logic can be property-tested directly. _extractRetiredPhaseNumbers: extractRetiredPhaseNumbers, // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated // steal decision is exercised without real pids. Mirrors capability-lock.cts. _setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void { if (typeof probes.isPidAlive === 'function') _stateLockProbes.isPidAlive = probes.isPidAlive; }, _resetLockProbes(): void { _stateLockProbes.isPidAlive = _realIsPidAlive; }, // Test seam (audit M8/M9): inject deterministic hooks for the scan-in-lock window // (afterAcquire), the one-shot recoverable writeSync failure (simulateWriteError), // and per-iteration orphan-lock snapshots (onLoopIteration). See _stateLockTestHooks. _setStateLockTestHooks(hooks: StateLockTestHooks): void { if ('afterAcquire' in hooks) _stateLockTestHooks.afterAcquire = hooks.afterAcquire; if ('simulateWriteError' in hooks) _stateLockTestHooks.simulateWriteError = hooks.simulateWriteError; if ('onLoopIteration' in hooks) _stateLockTestHooks.onLoopIteration = hooks.onLoopIteration; if ('beforeSteal' in hooks) _stateLockTestHooks.beforeSteal = hooks.beforeSteal; }, _resetStateLockTestHooks(): void { delete _stateLockTestHooks.afterAcquire; delete _stateLockTestHooks.simulateWriteError; delete _stateLockTestHooks.onLoopIteration; delete _stateLockTestHooks.beforeSteal; }, };