* test(#3257: full-line frontmatter comments survive the parse→reconstruct pair AND a mutating state verb parseYamlRegion dropped column-0 # comments and reconstructFrontmatter rebuilt from Object.entries alone, so full-line comments were silently destroyed on every mutating STATE verb. Add failing-first regressions: 3 unit tests for the public pair (comment between keys, leading+trailing, consecutive) and an e2e test running a state verb (state update) on a commented STATE.md — the e2e exercises syncStateFrontmatter's fresh-derivedFm rebuild path, which is the actual loss site the issue is filed against. RED — fails on next; fix follows. * fix(#3257: preserve full-line frontmatter comments through parse→reconstruct AND syncStateFrontmatter Carry column-0 # comments through the frontmatter pair via a Symbol-keyed channel (FULL_LINE_COMMENTS): parseYamlRegion captures ^# lines and attaches them to the next top-level key (leading) or a trailing slot; reconstructFrontmatter re-emits them in place. The Symbol is invisible to Object.entries/keys/JSON, so every existing reader is unchanged; the channel is created only when a comment is seen, so comment-less frontmatter is byte-identical. CRITICAL (isolated review): syncStateFrontmatter rebuilds its target via buildStateFrontmatter (fresh object) + an Object.keys carry-forward, both of which skip the Symbol — so the pair-preserving channel was lost on the very STATE verbs the issue names. Export propagateCommentChannel(source, target) from frontmatter.cts and call it in syncStateFrontmatter before reconstruct, copying the channel onto derivedFm (leading filtered to keys still present so a deleted key's annotation drops with it, trailing preserved). Decision A. * chore(#3257: add changeset fragment * chore(#3257: backfill changeset PR number (#3387) --------- Co-authored-by: sim <sim@local>
4069 lines
199 KiB
TypeScript
4069 lines
199 KiB
TypeScript
/**
|
||
* State — STATE.md operations and progression engine
|
||
*
|
||
* ADR-457 build-at-publish: the hand-written bin/lib/state.cjs collapsed
|
||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||
* from the prior hand-written .cjs; only strict types are added.
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import 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<string, unknown>;
|
||
type Frontmatter = Record<string, FrontmatterValue>;
|
||
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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<string>): 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<string, {
|
||
totalPhases: number;
|
||
completedPhases: number;
|
||
totalPlans: number;
|
||
completedPlans: number;
|
||
milestoneBounded: boolean;
|
||
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real `listMilestonePhaseDirs`
|
||
// scope for `allMatchingDirs` below, threaded through the cache so the
|
||
// percent computation at the bottom of buildStateFrontmatter can gate on it
|
||
// instead of hardcoding SCOPE.COMPLETE. Distinct from `milestoneBounded`
|
||
// (a heading-existence guard, #1761) — this one is disk-readability.
|
||
phaseDirScope: Scope;
|
||
}>();
|
||
|
||
// Track all lock files held by this process so they can be removed on exit.
|
||
// process.on('exit') fires even on process.exit(1), unlike try/finally which is
|
||
// skipped when error() calls process.exit(1) inside a locked region (#1916).
|
||
const _heldStateLocks = new Set<string>();
|
||
process.on('exit', () => {
|
||
for (const lockPath of _heldStateLocks) {
|
||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<string, string | boolean | undefined>;
|
||
const lines = [
|
||
`model_profile=${c['model_profile']}`,
|
||
`commit_docs=${c['commit_docs']}`,
|
||
`branching_strategy=${c['branching_strategy']}`,
|
||
`phase_branch_template=${c['phase_branch_template']}`,
|
||
`milestone_branch_template=${c['milestone_branch_template']}`,
|
||
`parallelization=${c['parallelization']}`,
|
||
`research=${c['research']}`,
|
||
`plan_checker=${c['plan_checker']}`,
|
||
`verifier=${c['verifier']}`,
|
||
`config_exists=${configExists}`,
|
||
`roadmap_exists=${roadmapExists}`,
|
||
`state_exists=${stateExists}`,
|
||
];
|
||
process.stdout.write(lines.join('\n'));
|
||
process.exit(0);
|
||
}
|
||
|
||
output(result, false, undefined);
|
||
}
|
||
|
||
function cmdStateGet(cwd: string, section: string | undefined, raw: boolean): void {
|
||
const statePath = planningPaths(cwd).state;
|
||
const content = platformReadSync(statePath);
|
||
if (content === null) {
|
||
error('STATE.md not found');
|
||
return;
|
||
}
|
||
{
|
||
|
||
if (!section) {
|
||
output({ content }, raw, content);
|
||
return;
|
||
}
|
||
|
||
// Try to find markdown section or field
|
||
const fieldEscaped = escapeRegex(section);
|
||
|
||
// Check for **field:** value (bold format)
|
||
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
||
const boldMatch = content.match(boldPattern);
|
||
if (boldMatch) {
|
||
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
|
||
return;
|
||
}
|
||
|
||
// Check for field: value (plain format)
|
||
const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
|
||
const plainMatch = content.match(plainPattern);
|
||
if (plainMatch) {
|
||
output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
|
||
return;
|
||
}
|
||
|
||
// Check for ## Section
|
||
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
|
||
const sectionMatch = content.match(sectionPattern);
|
||
if (sectionMatch) {
|
||
output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
|
||
return;
|
||
}
|
||
|
||
output({ error: `Section or field "${section}" not found` }, raw, '');
|
||
}
|
||
}
|
||
|
||
function readTextArgOrFile(cwd: string, value: string | undefined, filePath: string | undefined, label: string): string | undefined {
|
||
if (!filePath) return value;
|
||
|
||
// Path traversal guard: ensure file resolves within project directory
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
||
const { validatePath } = require('./security.cjs') as { validatePath(filePath: unknown, baseDir: unknown, opts?: { allowAbsolute?: boolean }): { safe: boolean; resolved: string; error?: string } };
|
||
const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
|
||
if (!pathCheck.safe) {
|
||
throw new Error(`${label} path rejected: ${pathCheck.error as string}`);
|
||
}
|
||
|
||
try {
|
||
return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
|
||
} catch {
|
||
throw new Error(`${label} file not found: ${filePath}`);
|
||
}
|
||
}
|
||
|
||
function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolean): void {
|
||
// Validate all field names before processing
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
||
const { validateFieldName } = require('./security.cjs') as { validateFieldName(field: unknown): { valid: boolean; error?: string } };
|
||
for (const field of Object.keys(patches)) {
|
||
const fieldCheck = validateFieldName(field);
|
||
if (!fieldCheck.valid) {
|
||
error(`state patch: ${fieldCheck.error as string}`);
|
||
}
|
||
}
|
||
|
||
const statePath = planningPaths(cwd).state;
|
||
try {
|
||
const 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<string, unknown> | 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<string, unknown> = { recorded: true, phase, plan, duration };
|
||
if (created) result['created'] = true;
|
||
output(result, raw, 'true');
|
||
}
|
||
|
||
function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
|
||
const statePath = planningPaths(cwd).state;
|
||
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
|
||
|
||
// Count summaries across current milestone phases only (outside lock — read-only)
|
||
const phasesDir = planningPaths(cwd).phases;
|
||
let totalPlans = 0;
|
||
let totalSummaries = 0;
|
||
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<string, unknown> = { added: true, decision: entry };
|
||
if (created) result['created'] = true;
|
||
output(result, raw, 'true');
|
||
}
|
||
|
||
function cmdStateAddBlocker(cwd: string, text: string | StateAddBlockerOptions, raw: boolean): void {
|
||
const statePath = planningPaths(cwd).state;
|
||
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
|
||
const blockerOptions: StateAddBlockerOptions = typeof text === 'object' && text !== null ? text : { text: text };
|
||
let blockerText: string | undefined = undefined;
|
||
|
||
try {
|
||
blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
|
||
} catch (err) {
|
||
output({ added: false, reason: (err as Error).message }, raw, 'false');
|
||
return;
|
||
}
|
||
|
||
if (!blockerText) { output({ error: 'text required' }, raw, undefined); return; }
|
||
|
||
const entry = `- ${blockerText}`;
|
||
let _added = false;
|
||
let created = false;
|
||
|
||
readModifyWriteStateMd(statePath, (content) => {
|
||
// 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { recorded: true, updated };
|
||
if (sessionCreated) result['created'] = true;
|
||
output(result, raw, 'true');
|
||
} else {
|
||
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<string, unknown> | null,
|
||
derivedFm: Record<string, unknown>,
|
||
): 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<string, unknown>, 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<string, unknown>;
|
||
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<string> {
|
||
const retired = new Set<string>();
|
||
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<string, unknown> {
|
||
// #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<string>();
|
||
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<string, string>(); // 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<string, unknown> = { gsd_state_version: '1.0' };
|
||
|
||
if (milestone) fm['milestone'] = milestone;
|
||
if (milestoneName) fm['milestone_name'] = milestoneName;
|
||
if (currentPhase) fm['current_phase'] = currentPhase;
|
||
if (currentPhaseName) fm['current_phase_name'] = currentPhaseName;
|
||
if (currentPlan) fm['current_plan'] = currentPlan;
|
||
fm['status'] = normalizedStatus;
|
||
if (stoppedAt) fm['stopped_at'] = stoppedAt;
|
||
if (pausedAt) fm['paused_at'] = pausedAt;
|
||
fm['last_updated'] = realClock.nowIso();
|
||
if (lastActivity) fm['last_activity'] = lastActivity;
|
||
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<string, unknown> = {};
|
||
if (totalPhases !== null) progress['total_phases'] = totalPhases;
|
||
if (completedPhases !== null) progress['completed_phases'] = completedPhases;
|
||
if (totalPlans !== null) progress['total_plans'] = totalPlans;
|
||
if (completedPlans !== null) progress['completed_plans'] = completedPlans;
|
||
if (progressPercent !== null) progress['percent'] = progressPercent;
|
||
if (Object.keys(progress).length > 0) fm['progress'] = progress;
|
||
|
||
return fm;
|
||
}
|
||
|
||
// ─── 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, unknown>): 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<string, unknown>;
|
||
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<string, unknown>).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<string, unknown>)?.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<T>(statePath: string, fn: () => T): T {
|
||
const lockPath = acquireStateLock(statePath);
|
||
try {
|
||
return fn();
|
||
} finally {
|
||
releaseStateLock(lockPath);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write STATE.md with synchronized YAML frontmatter.
|
||
* All STATE.md writes should use this instead of raw writeFileSync.
|
||
* Uses a simple lockfile to prevent parallel agents from overwriting
|
||
* each other's changes (race condition with read-modify-write cycle).
|
||
*
|
||
* @param statePath
|
||
* @param content
|
||
* @param cwd
|
||
* @param clock
|
||
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
||
*/
|
||
function writeStateMd(statePath: string, content: string, cwd?: string, clock?: StateLockClock): void {
|
||
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<string, unknown>;
|
||
|
||
// 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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
const body = stripFrontmatter(content);
|
||
|
||
// Always rebuild from body + disk so progress counters reflect current state.
|
||
// Returning cached frontmatter directly causes stale percent/completed_plans
|
||
// when SUMMARY files were added after the last STATE.md write (#1589).
|
||
const built = buildStateFrontmatter(body, cwd);
|
||
|
||
// Preserve frontmatter-only fields that cannot be recovered from the body.
|
||
if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
|
||
built['stopped_at'] = existingFm['stopped_at'];
|
||
}
|
||
if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
|
||
built['paused_at'] = existingFm['paused_at'];
|
||
}
|
||
// Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
|
||
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
|
||
built['status'] = existingFm['status'];
|
||
}
|
||
// Bug #905: preserve scalar fields when body annotations are absent.
|
||
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
|
||
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
|
||
built['current_phase'] = existingFm['current_phase'];
|
||
}
|
||
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
|
||
built['current_phase_name'] = existingFm['current_phase_name'];
|
||
}
|
||
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
|
||
built['current_plan'] = existingFm['current_plan'];
|
||
}
|
||
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
||
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
||
// rebuild from disk because they do not exceed the derived scan.
|
||
if (existingFm && shouldPreserveExistingProgress(existingFm['progress'], built['progress'])) {
|
||
built['progress'] = normalizeProgressNumbers(existingFm['progress']);
|
||
}
|
||
|
||
// #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(/(?<!\\)\|/).map((c) => 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<string, string>): 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<string, string>): 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 <vX.Y>)' }, 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<string, unknown>; body: string; scope: planningScopeMod.Scope } {
|
||
let fm: Record<string, unknown>;
|
||
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<string, unknown> = {};
|
||
|
||
// #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<string>();
|
||
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<string, unknown>).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<string, unknown>, r: boolean, v?: string) => output(result, r, v);
|
||
const statePath = planningPaths(cwd).state;
|
||
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
|
||
|
||
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
|
||
const dryRun = !!options.dryRun;
|
||
// 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<string, unknown>;
|
||
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<string, unknown>, r: boolean, v?: string) => output(result, r, v);
|
||
const statePath = planningPaths(cwd).state;
|
||
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
|
||
|
||
const 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: `<NN>-<slug>` (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<string, unknown>, 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 <N>' }, raw, undefined);
|
||
return;
|
||
}
|
||
|
||
// Idempotency guard (#3489). If STATE.md's canonical `Current Phase` field
|
||
// already names a phase distinct from the one we are being asked to mark
|
||
// complete, the project has advanced past the requested phase (e.g. a
|
||
// follow-up phase was inserted, or the next phase began). Re-running
|
||
// `state complete-phase --phase <N>` in that situation previously rolled
|
||
// STATE.md back to <N>'s moment-of-completion — silently clobbering Status,
|
||
// Last Activity, Last Activity Description, and the Current Position body.
|
||
// The handler is now a no-op in that case so re-invocation from downstream
|
||
// workflows cannot regress the project state.
|
||
const existingCurrentPhaseRaw = 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<string, unknown>;
|
||
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;
|
||
},
|
||
};
|