// sdk/state/index.ts — public API for the gdd-state module.
//
// This is the ONLY file consumers should import from. The module exposes
// exactly five surface-level names:
// * read(path) — parse STATE.md from disk
// * mutate(path, fn) — atomic read-modify-write under a lock
// * transition(path, toStage) — gate + stage-advance helper
// * ParsedState (type) — consumer-visible shape
// * Stage (type) — stage enum
//
// Plan 20-02 wired the real transition gates in via `gateFor(from, to)`
// imported from `./gates.ts`. Plan 20-04 migrated the error classes
// (TransitionGateFailed, LockAcquisitionError, ParseError) to the
// unified `gdd-errors` taxonomy — `types.ts` re-exports them verbatim
// so consumers of `gdd-state` need no changes.
//
// Phase 57 (Plan 57-06 Round 2-D): dual-write to SQLite when migration
// is active for this state file. The public API signatures are FROZEN
// (SC#5) — only the persistence path branches internally.
//
// Migration-active gate:
// migrationActive(statePath) === true
// iff BACKEND==='sqlite' AND existsSync(
/state.sqlite)
//
// When false (the universal default for un-migrated projects and all
// existing Phase-20 tests that use temp STATE.md paths), every function
// behaves EXACTLY as before Phase 57: pure markdown path, byte-identical,
// same lockfile, same events.
import {
readFileSync,
writeFileSync,
renameSync,
unlinkSync,
existsSync,
statSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
// Audit D1: this .ts compiles to CommonJS via tsc (Node16 module mode), where
// `import.meta` is FORBIDDEN (error TS1470). But under Node's
// --experimental-strip-types the same file runs as ESM, where the CommonJS
// globals `require` and `__dirname` are UNDEFINED -- a bare reference to either
// THROWS a ReferenceError, so we cannot name them directly. We satisfy BOTH
// targets by probing with `typeof` (safe in ESM) and falling back to the entry
// script (`process.argv[1]`) when the CJS globals are absent. This mirrors the
// process.argv[1] anchoring used by sibling `sdk/event-stream/writer.ts` and
// avoids `import.meta` entirely.
// * `_require` -- a CJS-style require, used to load the optional .cjs backend
// (state-backend.cjs) and package.json files. In compiled CJS output the
// real `require` is used; under strip-types ESM we synthesize one anchored
// on the entry script via createRequire.
// * `_moduleDir` -- the walk-up anchor formerly spelled `__dirname`. In
// compiled CJS `__dirname` is used; under ESM we derive a directory from
// the entry script. Either way `_loadBackend` can resolve the optional
// native backend in BOTH compiled and source modes.
const _moduleDir: string =
typeof __dirname !== 'undefined'
? __dirname
: dirname(process.argv[1] || process.cwd());
const _require =
typeof require !== 'undefined'
? require
: createRequire(process.argv[1] || process.cwd());
import { acquire, acquireSqliteLock } from './lockfile.ts';
import { parse } from './parser.ts';
import { serialize } from './mutator.ts';
import { gateFor } from './gates.ts';
import {
TransitionGateFailed,
isStage,
type ParsedState,
type Stage,
type TransitionResult,
} from './types.ts';
export type { ParsedState, Stage } from './types.ts';
export { TransitionGateFailed, LockAcquisitionError, ParseError } from './types.ts';
// ---------------------------------------------------------------------------
// Phase 57: package-root resolver (walk-up from sdk/state/index.ts location).
// ---------------------------------------------------------------------------
/**
* Walk up from `startDir` to find the package root (directory with
* package.json named '@hegemonart/get-design-done').
* Falls back to the first directory that has any package.json, then null.
*/
function _findPackageRoot(startDir: string): string | null {
let dir = resolve(startDir);
let firstWithPkg: string | null = null;
for (let i = 0; i < 12; i++) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
try {
// D1: createRequire-bound require (bare `require` is undefined in ESM).
const pkg = _require(pkgPath) as { name?: string };
if (firstWithPkg === null) firstWithPkg = dir;
if (pkg.name === '@hegemonart/get-design-done') return dir;
} catch {
if (firstWithPkg === null) firstWithPkg = dir;
}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return firstWithPkg;
}
// ---------------------------------------------------------------------------
// Phase 57: backend probe (loaded once via createRequire, memoized).
// state-backend.cjs is a CommonJS module. Audit D1 correction: a BARE
// `require()` does NOT work from this .ts under Node's --experimental-strip-
// types — this module is loaded as ESM, where `require` is undefined and a
// bare call throws ReferenceError (silently caught below, killing the backend
// path). We load via `_require` -- the real CJS `require` in compiled output,
// or a createRequire anchored on the entry script under strip-types ESM --
// which DOES resolve the optional .cjs backend in both modes. The graceful-null
// fallback is preserved for the genuinely-absent
// dependency (e.g. better-sqlite3 not installed).
// ---------------------------------------------------------------------------
interface StateBackendMod {
Database: ((...args: unknown[]) => unknown) | null;
BACKEND: 'sqlite' | 'markdown';
sqlitePath: (projectRoot: string) => string;
openStateDb: (p: string, opts?: { readonly?: boolean }) => StateDb;
}
interface StateDb {
close(): void;
prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[]; run(...args: unknown[]): unknown };
transaction(fn: () => void): () => void;
pragma(s: string): unknown;
exec(s: string): void;
}
/** null = not yet loaded, false = failed to load, StateBackendMod = loaded */
let _backendCache: StateBackendMod | null | false = null;
function _loadBackend(): StateBackendMod | null {
if (_backendCache !== null) return _backendCache === false ? null : _backendCache as StateBackendMod;
try {
const pkgRoot = _findPackageRoot(_moduleDir);
if (pkgRoot === null) { _backendCache = false; return null; }
const backendPath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-backend.cjs');
if (!existsSync(backendPath)) { _backendCache = false; return null; }
// D1: createRequire-bound require so the optional native backend can
// actually load from this ESM (.ts strip-types) context.
_backendCache = _require(backendPath) as StateBackendMod;
return _backendCache as StateBackendMod;
} catch {
_backendCache = false;
return null;
}
}
// ---------------------------------------------------------------------------
// Phase 57: state-store loader (async, cached after first successful load).
// state-store.cjs itself does dynamic imports of .ts SDK files, so we load
// it via dynamic import (not require) to stay in async context.
// ---------------------------------------------------------------------------
interface StateStoreMod {
appendDecision: (...args: unknown[]) => Promise;
getDecisions: (...args: unknown[]) => unknown[];
setPosition: (...args: unknown[]) => Promise;
getPosition: (...args: unknown[]) => unknown;
backendName: () => string;
[key: string]: unknown;
}
let _storeCache: StateStoreMod | null | false = null;
async function _loadStore(): Promise {
if (_storeCache !== null) return _storeCache === false ? null : _storeCache as StateStoreMod;
try {
const pkgRoot = _findPackageRoot(_moduleDir);
if (pkgRoot === null) { _storeCache = false; return null; }
const storePath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-store.cjs');
if (!existsSync(storePath)) { _storeCache = false; return null; }
const mod = await import(pathToFileURL(storePath).href);
_storeCache = mod as StateStoreMod;
return _storeCache as StateStoreMod;
} catch {
_storeCache = false;
return null;
}
}
// ---------------------------------------------------------------------------
// Phase 57: migration-active gate.
//
// migrationActive(statePath) === true iff:
// 1. BACKEND === 'sqlite' (better-sqlite3 + FTS5 available, env not forcing markdown)
// 2. existsSync(join(dirname(statePath), 'state.sqlite'))
//
// The sibling check uses dirname(statePath) directly so every temp-dir test
// that creates a STATE.md WITHOUT a sibling state.sqlite is correctly NOT
// considered migrated — this is the universal default.
//
// This gate is what keeps all existing Phase-20 tests green with better-sqlite3
// present: they use temp paths with no state.sqlite sibling, so migrationActive
// returns false and the pure-markdown path runs byte-identically.
// ---------------------------------------------------------------------------
function migrationActive(statePath: string): boolean {
const backend = _loadBackend();
if (backend === null || backend.BACKEND !== 'sqlite') return false;
const sqliteSibling = join(dirname(statePath), 'state.sqlite');
if (!existsSync(sqliteSibling)) return false;
// BUG-07: a DIRECTORY named state.sqlite would cause existsSync to return true,
// and then every mutate() would throw when trying to open it as a database.
// Guard: if the path is a directory, treat migration as inactive.
try {
if (statSync(sqliteSibling).isDirectory()) return false;
} catch {
return false;
}
return true;
}
/**
* Return the path to the sibling state.sqlite for a given STATE.md path.
* This is always `/state.sqlite`.
* Exported for use by tests and lockfile helpers.
*/
export function sqlitePathFor(statePath: string): string {
return join(dirname(statePath), 'state.sqlite');
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Read STATE.md from disk and return the parsed state.
*
* Phase 57 dual-write: when migration is active, reads the on-disk STATE.md
* (which the dual-write path keeps byte-equal with SQLite state), then parses
* it. This matches the pre-Phase-57 behavior of this function and keeps the
* returned ParsedState shape identical whether reading via SQLite or markdown.
*
* Shared-read: no lock is taken. Reads are snapshot-safe (atomic rename by
* `mutate()` means we either see the old file or the new file, never a torn
* write).
*/
export async function read(path: string): Promise {
// Phase 57: both paths parse the on-disk STATE.md (the dual-write path
// maintains STATE.md as a byte-equal render of SQLite state). No behavioral
// difference; the branch is here for future extension (e.g., reading directly
// from SQLite without touching disk). The returned ParsedState is identical
// whether migration is active or not.
const raw: string = readFileSync(path, 'utf8');
return parse(raw).state;
}
/**
* Atomic read-modify-write on STATE.md.
*
* Phase 57 dual-write: when migration is active for this statePath, acquires
* the SQLite sibling lock BEFORE the STATE.md lock (R9 deadlock-free
* ordering), then after applying fn() serializes to markdown and writes
* atomically (same .tmp+rename path). The on-disk STATE.md is always the
* byte-equal rendered form, so SQLite stays consistent via the R8 freshness
* guard on the next state-store operation.
*
* When migration is inactive, behavior is EXACTLY as pre-Phase-57:
* 1. Acquire sibling `.lock` file (PID+timestamp advisory lock).
* 2. Read current contents.
* 3. Apply `fn`.
* 4. Serialize to a `.tmp` file next to `path`.
* 5. `renameSync(.tmp, path)` — POSIX-atomic; Windows EPERM retry once.
* 6. Release the lock (in `finally` — released even on mid-fn throw).
*
* Crash between write and rename is benign: STATE.md is untouched; the
* `.tmp` file is orphaned (cleaned up on the next acquire by the caller).
*/
export async function mutate(
path: string,
fn: (s: ParsedState) => ParsedState,
): Promise {
if (migrationActive(path)) {
return _mutateSqliteActive(path, fn);
}
return _mutateMarkdown(path, fn);
}
/**
* Pure markdown mutate path (pre-Phase-57 behavior, unchanged).
* Called when migrationActive() === false.
*/
async function _mutateMarkdown(
path: string,
fn: (s: ParsedState) => ParsedState,
): Promise {
const release = await acquire(path);
const tmpPath: string = `${path}.tmp`;
try {
const raw: string = readFileSync(path, 'utf8');
const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
parse(raw);
const clone = structuredClone(state);
const next = fn(clone);
const out = serialize(next, {
raw_frontmatter,
raw_bodies,
block_gaps,
line_ending,
});
writeFileSync(tmpPath, out, 'utf8');
try {
renameSync(tmpPath, path);
} catch (err) {
// Windows EPERM retry — AV / indexer holding STATE.md briefly.
const code =
typeof err === 'object' && err !== null && 'code' in err
? (err as { code?: unknown }).code
: undefined;
if (code === 'EPERM' || code === 'EBUSY') {
await new Promise((r) => setTimeout(r, 50));
renameSync(tmpPath, path);
} else {
throw err;
}
}
return next;
} catch (err) {
try {
if (existsSync(tmpPath)) unlinkSync(tmpPath);
} catch {
// best-effort cleanup.
}
throw err;
} finally {
await release();
}
}
/**
* SQLite-active mutate path (Phase 57, migrationActive() === true).
*
* Lock ordering per R9: acquire state.sqlite.lock BEFORE STATE.md.lock
* (deadlock-free). Both released in finally.
*
* Write strategy: serialize next state to markdown, write atomically
* (.tmp + rename). This keeps STATE.md byte-equal with the last parsed
* state. The R8 freshness guard in state-store detects the sha-change on
* the next state-store call and re-syncs SQLite from the markdown SoT.
* This avoids duplicating state-store's row-level write logic here while
* still keeping SQLite eventually consistent.
*/
async function _mutateSqliteActive(
path: string,
fn: (s: ParsedState) => ParsedState,
): Promise {
const sqlitePath = join(dirname(path), 'state.sqlite');
// Acquire SQLite lock first (R9: state.sqlite.lock before STATE.md.lock).
const releaseSqliteLock = await acquireSqliteLock(sqlitePath);
const release = await acquire(path);
const tmpPath: string = `${path}.tmp`;
try {
const raw: string = readFileSync(path, 'utf8');
const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
parse(raw);
const clone = structuredClone(state);
const next = fn(clone);
const out = serialize(next, {
raw_frontmatter,
raw_bodies,
block_gaps,
line_ending,
});
writeFileSync(tmpPath, out, 'utf8');
try {
renameSync(tmpPath, path);
} catch (err) {
const code =
typeof err === 'object' && err !== null && 'code' in err
? (err as { code?: unknown }).code
: undefined;
if (code === 'EPERM' || code === 'EBUSY') {
await new Promise((r) => setTimeout(r, 50));
renameSync(tmpPath, path);
} else {
throw err;
}
}
return next;
} catch (err) {
try {
if (existsSync(tmpPath)) unlinkSync(tmpPath);
} catch {
// best-effort cleanup.
}
throw err;
} finally {
await release();
await releaseSqliteLock();
}
}
/**
* Advance to `toStage` under the locked RMW protocol.
*
* Steps:
* 1. Read current state (outside the lock) to pass to the gate.
* 2. Resolve the gate via `gateFor(position.stage, toStage)`.
* - `null` → TransitionGateFailed "Invalid transition" (skip-stage,
* backward, same-stage, or from outside the Stage union).
* 3. Invoke the gate. If `pass: false`, throw TransitionGateFailed with
* the gate's blockers verbatim.
* 4. If `pass: true`, mutate STATE.md under the lock:
* - frontmatter.stage = toStage
* - position.stage = toStage
* - frontmatter.last_checkpoint = now (ISO)
* - timestamps[`${toStage}_started_at`] = now (ISO)
*
* Phase 57: the gate evaluation + stamping logic is unchanged. The
* persistence path branches via `mutate()` which applies the
* migration-active gate internally.
*
* Returns the updated state plus the gate response (for callers that
* want to log blockers — on pass, `blockers` is always `[]`).
*/
export async function transition(
path: string,
toStage: Stage,
): Promise {
// Read (outside the lock) to pass current state to the gate — the
// mutate() below will re-read under the lock before applying changes.
// This two-phase pattern matches the GSD reference implementation.
const beforeMutate = await read(path);
const from: string = beforeMutate.position.stage;
// `position.stage` is typed as `string` in ParsedState (parser tolerates
// `scan` and other pre-brief values). Narrow it to `Stage` before asking
// the gate registry — anything outside the union is an invalid FROM.
if (!isStage(from)) {
throw new TransitionGateFailed(toStage, [
`Invalid transition: from="${from}" is not a recognized Stage`,
]);
}
const gate = gateFor(from, toStage);
if (gate === null) {
throw new TransitionGateFailed(toStage, [
`Invalid transition: ${from} → ${toStage}`,
]);
}
const gateResult = gate(beforeMutate);
if (!gateResult.pass) {
throw new TransitionGateFailed(toStage, gateResult.blockers);
}
const nowIso: string = new Date().toISOString();
// Audit D4: the gate above was evaluated against a PRE-LOCK read. A
// concurrent stage change between that read and the locked mutate could make
// the transition invalid (e.g. another writer already advanced the stage, so
// `from` is no longer the current stage, or the gate's preconditions no
// longer hold). Re-evaluate the gate INSIDE the locked mutate against the
// freshly-read `s`, and abort the transition if it no longer holds. The
// mutate() lock serializes us against other writers, so this re-check is
// race-free: nothing can change `s` between this check and the write.
//
// We capture the locked re-check failure and re-throw it OUTSIDE mutate so
// the caller sees a TransitionGateFailed rather than a generic mutate error.
let lockedFailure: TransitionGateFailed | null = null;
let lockedBlockers: string[] = gateResult.blockers;
try {
const nextState = await mutate(path, (s): ParsedState => {
const fromNow: string = s.position.stage;
if (!isStage(fromNow)) {
lockedFailure = new TransitionGateFailed(toStage, [
`Invalid transition: from="${fromNow}" is not a recognized Stage (changed under lock)`,
]);
throw lockedFailure;
}
const gateNow = gateFor(fromNow, toStage);
if (gateNow === null) {
lockedFailure = new TransitionGateFailed(toStage, [
`Invalid transition: ${fromNow} → ${toStage} (changed under lock)`,
]);
throw lockedFailure;
}
const resultNow = gateNow(s);
if (!resultNow.pass) {
lockedFailure = new TransitionGateFailed(toStage, resultNow.blockers);
throw lockedFailure;
}
lockedBlockers = resultNow.blockers;
s.frontmatter.stage = toStage;
s.frontmatter.last_checkpoint = nowIso;
s.position.stage = toStage;
s.timestamps[`${toStage}_started_at`] = nowIso;
return s;
});
return { pass: true, blockers: lockedBlockers, state: nextState };
} catch (err) {
// If the in-lock re-check vetoed, surface the gate failure verbatim.
if (lockedFailure !== null && err === lockedFailure) throw lockedFailure;
throw err;
}
}