diff --git a/scripts/em-recall.mjs b/scripts/em-recall.mjs index 0a9d5c1..8b74e41 100644 --- a/scripts/em-recall.mjs +++ b/scripts/em-recall.mjs @@ -21,7 +21,6 @@ import os from 'os' import { execSync } from 'child_process' import { resolveLocalDir, resolveRepoRoot } from './lib/local-dir.mjs' import { - TASK_SIGNAL_MARKERS, BASELINE_NAME, PRIMARY_MARKER_DIR, LEGACY_MARKER_DIR, @@ -33,15 +32,15 @@ import { ensurePrimaryDir, bothMarkerPaths, namespacedMarkerBasenameForSession, - CHECKPOINT_QUARTET, preflightMarkerSuffixedBasenameMatches, lastUserPromptBasenameMatches, } from './lib/marker-paths.mjs' import { _maxMtimeAcrossRootsStrict, _maxMtimeAcrossRootsForPlanMarkerStrict, - _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict, -} from './lib/stop-gate-helpers.mjs' + resolveOwnSessionMarkerRead, + stopGateCarveOutApplies, +} from './lib/marker-state.mjs' import { validateSessionId } from './lib/session-id.mjs' const GLOBAL_DIR = path.join(os.homedir(), '.episodic-memory') @@ -128,171 +127,16 @@ if (sessionStartFlag && gateFlag !== undefined) { } // --------------------------------------------------------------------------- -// Task-signal markers — the closed set of files whose mtime distinguishes -// "fresh task work this session" from "stale from prior". Imported from -// scripts/lib/marker-paths.mjs (single source of truth shared with hook). -// Extended class members MUST be added there. -// -// 2026-05-09 .checkpoints/ migration: marker writes go to PRIMARY (.checkpoints/) -// only; reads check PRIMARY first then fall back to LEGACY (.claude/) until -// burn-in completes. Carve-out, orphan-clear, and baseline checks all use -// the shared marker-paths helpers — see scripts/lib/marker-paths.mjs. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Stop-gate carve-out (#146 A2). Pure function — testable in isolation. -// -// Returns true iff the stop-gate should treat the current turn as having no -// real task signal (e.g. session-start handoff y/n + workplan display) and -// allow stop despite an armed .checkpoint-required. -// -// Invariant: every TASK_SIGNAL_MARKERS member at EITHER root (primary or -// legacy) must be either absent or have mtime <= .session-baseline mtime. -// A signal mtime > baseline means it was created/touched mid-session, -// which is the case the gate must catch. -// -// Dual-root semantics (.checkpoints/ migration): baselineMtime is the MAX -// of primary and legacy baseline mtimes (whichever is most recent). -// Per-marker mtime is the MAX across both roots. -// -// .session-baseline is written/touched by em-recall --session-start (called -// from hooks/em-recall-sessionstart.sh). If missing at both roots, the -// carve-out does not apply (conservative — pre-existing sessions before -// this fix shipped). -// -// SubagentStop semantics (P1-1): the same predicate runs for SubagentStop. -// A subagent that wrote files would have caused checkpoint-gate to arm -// .post-checkpoint-required (mtime > baseline), denying the carve-out. A -// subagent that did read-only work satisfies the carve-out — same semantics -// as the parent's no-task-signal turn, which is the desired behavior. -// -// Symlink defense (P2-2): uses lstatSync so a symlink to an old file cannot -// trick the carve-out into firing. ANY symlink — baseline or marker, at -// EITHER root — causes the carve-out to FAIL CLOSED. Same-class symmetry -// per feedback_same_class_completeness.md. Codex round-1 P2 finding -// (episode 20260505-124511-...-845f) reproduced the asymmetry. +// Marker-state reads moved to scripts/lib/marker-state.mjs (RFC-008 P3a, R1). +// The carve-out predicate, relaxed mtime helpers, and own-session resolver now +// live in the enforcement-owned marker-state module; em-recall imports only the +// four helpers its surviving `--gate stop` dispatch handler still calls +// (_maxMtimeAcrossRootsStrict, _maxMtimeAcrossRootsForPlanMarkerStrict, +// resolveOwnSessionMarkerRead, stopGateCarveOutApplies). TASK_SIGNAL_MARKERS +// and CHECKPOINT_QUARTET moved with the carve-out and are no longer imported +// here. The dispatch handler itself moves to enforce-contract.mjs in P3b and +// is deleted here in P3d. // --------------------------------------------------------------------------- -function _maxMtimeAcrossRoots(repoRoot, basename) { - // Returns { mtime, hadSymlink, anyExisted }. mtime is the max across both - // roots. If either side is a symlink, hadSymlink=true (caller fails closed). - let mtime = -Infinity - let hadSymlink = false - let anyExisted = false - for (const p of [primaryMarkerPath(repoRoot, basename), legacyMarkerPath(repoRoot, basename)]) { - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch {} - } - return { mtime, hadSymlink, anyExisted } -} - -// #268 fix E19: non-strict plan-marker variant for carve-out. Scans BOTH -// legacy `.plan-approval-pending` AND any `.plan-approval-pending.` -// at primary + legacy roots; returns max mtime across the set. -// -// Symmetric with _maxMtimeAcrossRoots (relaxed: lstat errors silently -// skipped). Use this for carve-out (NON-fail-closed) sites; for stop-gate -// fail-closed sites use _maxMtimeAcrossRootsForPlanMarkerStrict from -// stop-gate-helpers.mjs. -function _maxMtimeAcrossRootsForPlanMarker(repoRoot) { - let mtime = -Infinity - let hadSymlink = false - let anyExisted = false - for (const p of [ - primaryMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), - legacyMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), - ]) { - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch {} - } - const prefix = `${PLAN_MARKER_LEGACY_BASENAME}.` - for (const dir of [path.join(repoRoot, PRIMARY_MARKER_DIR), path.join(repoRoot, LEGACY_MARKER_DIR)]) { - let entries - try { entries = fs.readdirSync(dir) } catch { continue } - for (const name of entries) { - if (!name.startsWith(prefix)) continue - const p = path.join(dir, name) - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch {} - } - } - return { mtime, hadSymlink, anyExisted } -} - -// Rank-2: resolve a session-aware marker read. Resolution order: -// 1. /.checkpoints/. (own-session, primary) -// 2. /.claude/. (own-session, legacy root) -// 3. /.checkpoints/ (legacy literal, primary) -// 4. /.claude/ (legacy literal, legacy root) -// -// Returns the first existing path or null. Other sessions' suffixed -// markers are intentionally NOT probed — own-session semantic per -// rank-2 plan §3 trust model. -// -// When sid is null/empty, only steps 3-4 are tried (legacy-literal-only -// fallback for invalid/missing sid). Symlink-aware via fs.existsSync -// (which follows links); callers needing symlink-fail-closed must -// re-check with lstatSync. -function resolveOwnSessionMarkerRead(repoRoot, legacyBasename, sid) { - if (sid) { - const ownBasename = namespacedMarkerBasenameForSession(legacyBasename, sid) - const ownPrimary = primaryMarkerPath(repoRoot, ownBasename) - if (fs.existsSync(ownPrimary)) return ownPrimary - const ownLegacy = legacyMarkerPath(repoRoot, ownBasename) - if (fs.existsSync(ownLegacy)) return ownLegacy - } - const litPrimary = primaryMarkerPath(repoRoot, legacyBasename) - if (fs.existsSync(litPrimary)) return litPrimary - const litLegacy = legacyMarkerPath(repoRoot, legacyBasename) - if (fs.existsSync(litLegacy)) return litLegacy - return null -} - -function stopGateCarveOutApplies(repoRoot, sid) { - const base = _maxMtimeAcrossRoots(repoRoot, BASELINE_NAME) - if (base.hadSymlink) return false - if (!base.anyExisted) return false - const baselineMtime = base.mtime - - for (const name of TASK_SIGNAL_MARKERS) { - let m - if (name === PLAN_MARKER_LEGACY_BASENAME) { - // #268 fix E19: plan-marker member glob-expands suffixed forms - // (cross-session — plan-pending deferral is global-by-design). - m = _maxMtimeAcrossRootsForPlanMarker(repoRoot) - } else if (CHECKPOINT_QUARTET.includes(name)) { - // Rank-2 (codex R2 P1-B + R4 ACCEPT): quartet carve-out is - // OWN-SESSION-ONLY — read own `.` + legacy literal, - // NEVER other sessions' suffixed forms. Cross-session safety is - // delegated to SessionStart's force-monotonic baseline probe. - // Strict catch (R2 P2): non-ENOENT errors fail closed. - const strict = _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict( - repoRoot, name, sid) - if (strict.hadOtherError) return false - m = strict - } else { - m = _maxMtimeAcrossRoots(repoRoot, name) - } - // Symlink at either root → fail closed. - if (m.hadSymlink) return false - // Marker absent at both roots → no signal; skip. - if (!m.anyExisted) continue - // Mid-session signal → fail closed. - if (m.mtime > baselineMtime) return false - } - return true -} if (gateFlag === 'stop') { // REPO_ROOT was resolved at module load (line ~26) via resolveRepoRoot() diff --git a/scripts/lib/marker-paths.mjs b/scripts/lib/marker-paths.mjs index deb1b15..b2dfdf4 100644 --- a/scripts/lib/marker-paths.mjs +++ b/scripts/lib/marker-paths.mjs @@ -539,8 +539,10 @@ export const PLAN_MARKER_ENFORCEMENT_SITES = [ // E10: plan-gate.sh existence check + marker_write allowlist. { file: 'plugins/claude-code/hooks/plan-gate.sh', line: 57, role: 'PLAN_PENDING_W resolution + existence check + marker_write allowlist (session-aware after #268 fix)', kind: 'shell-equality', semantic_role: 'read-own-session' }, - // E11: scripts/em-recall.mjs TASK_SIGNAL_MARKERS array literal (consumer). - { file: 'scripts/em-recall.mjs', line: 97, role: 'TASK_SIGNAL_MARKERS array literal (consumer)', kind: 'js-array', semantic_role: 'read-any' }, + // E11: scripts/lib/marker-state.mjs plan-marker consumer (relocated from + // em-recall.mjs in RFC-008 P3a — carve-out + relaxed mtime helpers now live + // in the enforcement-owned marker-state module). + { file: 'scripts/lib/marker-state.mjs', line: 0, role: 'plan-marker carve-out consumer (PLAN_MARKER_LEGACY_BASENAME branch + relaxed/strict mtime helpers)', kind: 'js-array', semantic_role: 'read-any' }, // E12: scripts/em-audit-compliance.mjs compliance regex. { file: 'scripts/em-audit-compliance.mjs', line: 111, role: 'compliance audit regex (\\.plan-approval-pending\\b accepts both forms)', kind: 'js-regex', semantic_role: 'read-any' }, @@ -558,9 +560,13 @@ export const PLAN_MARKER_ENFORCEMENT_SITES = [ { file: 'plugins/claude-code/hooks/checkpoint-gate.sh', line: 459, role: 'pre-checkpoint gate (.checkpoint-required — DIFFERENT marker)', kind: 'shell-decoupled', semantic_role: 'decoupled' }, { file: 'plugins/claude-code/hooks/checkpoint-gate.sh', line: 497, role: 'pre→post arming gate (.checkpoint-required — DIFFERENT marker)', kind: 'shell-decoupled', semantic_role: 'decoupled' }, - // E19-E20: em-recall.mjs iteration consumers. - { file: 'scripts/em-recall.mjs', line: 170, role: 'TASK_SIGNAL_MARKERS carve-out loop (glob-expands suffixed forms)', kind: 'js-array-iter', semantic_role: 'read-any' }, - { file: 'scripts/em-recall.mjs', line: 587, role: 'TASK_SIGNAL_MARKERS orphan-clear sweep (non-plan-marker class only; plan-marker handled by sibling unconditional sweep — post-2026-05-18 deadlock fix)', kind: 'js-array-iter', semantic_role: 'sweep-stale' }, + // E19: carve-out loop — relocated to scripts/lib/marker-state.mjs in + // RFC-008 P3a (stopGateCarveOutApplies + _maxMtimeAcrossRootsForPlanMarker + // glob-expand suffixed forms). + { file: 'scripts/lib/marker-state.mjs', line: 0, role: 'TASK_SIGNAL_MARKERS carve-out loop (glob-expands suffixed forms)', kind: 'js-array-iter', semantic_role: 'read-any' }, + // E20: em-recall.mjs orphan-clear sweep (stays in em-recall — SessionStart + // legacy plan-marker sweep, not part of the P3a carve-out move). + { file: 'scripts/em-recall.mjs', line: 587, role: 'legacy plan-marker orphan-clear sweep (PLAN_MARKER_LEGACY_BASENAME, suffix-less; post-2026-05-18 deadlock fix)', kind: 'js-array-iter', semantic_role: 'sweep-stale' }, // E20b: NEW unconditional legacy-suffix-less plan-marker sweep above the // baseline guard. Suffixed forms `.plan-approval-pending.` are diff --git a/scripts/lib/marker-state.mjs b/scripts/lib/marker-state.mjs new file mode 100644 index 0000000..7851d9f --- /dev/null +++ b/scripts/lib/marker-state.mjs @@ -0,0 +1,340 @@ +/** + * marker-state.mjs — Enforcement-layer marker-state reader (RFC-008 P3, R1). + * + * Owns ALL gate/marker reads for the enforcement layer. The memory substrate + * (em-store / em-recall / em-search) MUST NOT read markers — that is the R1 + * strong-form invariant (RFC-008:83,85). This module is imported by the + * enforcement thin waist (enforce-contract.mjs, P3b) and, during the P3a→P3d + * transition, by em-recall.mjs's surviving `--gate stop` dispatch handler + * (which itself moves to enforce-contract.mjs in P3b, then is deleted from + * em-recall in P3d). + * + * P3a is a pure extraction: every function here is relocated VERBATIM (same + * semantics, same fail-closed behavior) from its prior home — + * - the 3 strict-lstat helpers from scripts/lib/stop-gate-helpers.mjs + * (now deleted; its sole importer was em-recall.mjs); + * - the relaxed helpers + carve-out predicate + own-session resolver from + * scripts/em-recall.mjs:175-295. + * No behavior change: em-recall `--gate stop` output is byte-identical. + * + * Path layer stays in marker-paths.mjs (primary/legacy roots, suffixed-marker + * matchers, the marker-name constants); this module imports from it. + * + * Strict vs relaxed semantic: + * - *Strict helpers distinguish ENOENT (marker absent at a root → skip) from + * other lstat errors (EACCES, ENOTDIR, EIO, ELOOP → hadOtherError → caller + * fails closed). Use for NEW fail-closed paths. + * - The non-`Strict` helpers use relaxed semantics (lstat errors silently + * skipped) — carve-out callers (NON-fail-closed). + * ANY symlink at EITHER root sets hadSymlink=true so the caller fails closed + * (symlink-defense, same-class symmetry per feedback_same_class_completeness). + * + * Codex review trail (strict helpers): rounds 1-7 of rank-1 hook-deadlock plan, + * episodes `...bd6c` → `...afd2` → `...3ad6` → `...5697` → `...fb05` → `...acdc` + * → `...e19a` (ACCEPT-with-FU). Carve-out symlink defense: codex round-1 P2 + * (episode 20260505-124511-...-845f). + */ + +import fs from 'fs' +import path from 'path' +import { + TASK_SIGNAL_MARKERS, + BASELINE_NAME, + PRIMARY_MARKER_DIR, + LEGACY_MARKER_DIR, + PLAN_MARKER_LEGACY_BASENAME, + CHECKPOINT_QUARTET, + primaryMarkerPath, + legacyMarkerPath, + namespacedMarkerBasenameForSession, +} from './marker-paths.mjs' + +// --------------------------------------------------------------------------- +// Strict-lstat helpers (relocated from stop-gate-helpers.mjs). +// +// `_maxMtimeAcrossRootsStrict` — distinguishes ENOENT (marker absent at a root +// → fine to skip) from other lstat errors (EACCES, ENOTDIR, EIO, ELOOP — +// inspection failed → caller must fail closed). For fail-closed paths. +// --------------------------------------------------------------------------- +export function _maxMtimeAcrossRootsStrict(repoRoot, basename) { + let mtime = -Infinity + let hadSymlink = false + let anyExisted = false + let hadOtherError = false + for (const p of [primaryMarkerPath(repoRoot, basename), legacyMarkerPath(repoRoot, basename)]) { + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch (e) { + if (e && e.code !== 'ENOENT') hadOtherError = true + } + } + return { mtime, hadSymlink, anyExisted, hadOtherError } +} + +// #268 fix E19/E20: strict-lstat helper for plan-marker that scans BOTH +// legacy `.plan-approval-pending` AND any `.plan-approval-pending.` +// at primary + legacy roots. Mtime is the MAX across the entire set — +// stop-gate's plan-pending deferral fires if ANY plan-marker (own session +// or other) is active mid-session. +// +// Same fail-closed semantics as the base strict helper: +// - hadSymlink on any matched symlink (caller fails closed) +// - hadOtherError on any non-ENOENT lstat / readdir error +export function _maxMtimeAcrossRootsForPlanMarkerStrict(repoRoot) { + let mtime = -Infinity + let hadSymlink = false + let anyExisted = false + let hadOtherError = false + + // Legacy literal at both roots (same as _maxMtimeAcrossRootsStrict for + // PLAN_MARKER_LEGACY_BASENAME). + for (const p of [ + primaryMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), + legacyMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), + ]) { + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch (e) { + if (e && e.code !== 'ENOENT') hadOtherError = true + } + } + + // Glob-expand `.plan-approval-pending.<*>` at both roots. + const prefix = `${PLAN_MARKER_LEGACY_BASENAME}.` + for (const dir of [path.join(repoRoot, PRIMARY_MARKER_DIR), path.join(repoRoot, LEGACY_MARKER_DIR)]) { + let entries + try { + entries = fs.readdirSync(dir) + } catch (e) { + // ENOENT on the dir itself is fine (no markers); other → fail-closed. + if (e && e.code !== 'ENOENT') hadOtherError = true + continue + } + for (const name of entries) { + if (!name.startsWith(prefix)) continue + const p = path.join(dir, name) + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch (e) { + if (e && e.code !== 'ENOENT') hadOtherError = true + } + } + } + + return { mtime, hadSymlink, anyExisted, hadOtherError } +} + +// Rank-2 (PR for checkpoint-quartet) — own-session strict helper for the +// 4 checkpoint quartet markers. Per codex plan-tier R2 P1-B + R4 ACCEPT: +// the quartet carve-out is OWN-SESSION-ONLY, NOT cross-session glob like +// the plan-marker. Cross-session safety for the quartet is delegated to +// SessionStart's force-monotonic baseline probe (em-recall.mjs); the +// stop-gate carve-out at turn-end reads only this session's own marker +// (plus legacy literal during burn-in). +// +// Strict catch (R2 P2): non-ENOENT lstat errors → hadOtherError → caller +// fails closed. Sibling of _maxMtimeAcrossRootsStrict. +// +// @param {string} repoRoot +// @param {string} legacyBasename — one of CHECKPOINT_QUARTET members +// @param {string|null} sid — own session id, or null/empty → legacy-only mode +// @returns {{mtime, hadSymlink, anyExisted, hadOtherError}} +export function _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict( + repoRoot, legacyBasename, sid +) { + let mtime = -Infinity + let hadSymlink = false + let anyExisted = false + let hadOtherError = false + + // Build paths to probe — own-session suffixed AND legacy literal, each + // at both roots. Other sessions' suffixed markers NOT included — + // that's the point of the rank-2 fix. + const paths = [ + primaryMarkerPath(repoRoot, legacyBasename), + legacyMarkerPath(repoRoot, legacyBasename), + ] + if (sid) { + const ownBasename = `${legacyBasename}.${sid}` + paths.push(primaryMarkerPath(repoRoot, ownBasename)) + paths.push(legacyMarkerPath(repoRoot, ownBasename)) + } + + for (const p of paths) { + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch (e) { + if (e && e.code !== 'ENOENT') hadOtherError = true + } + } + + return { mtime, hadSymlink, anyExisted, hadOtherError } +} + +// --------------------------------------------------------------------------- +// Relaxed helpers + carve-out predicate (relocated from em-recall.mjs:175-295). +// +// Stop-gate carve-out (#146 A2). Pure function — testable in isolation. +// +// Returns true iff the stop-gate should treat the current turn as having no +// real task signal (e.g. session-start handoff y/n + workplan display) and +// allow stop despite an armed .checkpoint-required. +// +// Invariant: every TASK_SIGNAL_MARKERS member at EITHER root (primary or +// legacy) must be either absent or have mtime <= .session-baseline mtime. +// A signal mtime > baseline means it was created/touched mid-session, +// which is the case the gate must catch. +// +// Dual-root semantics (.checkpoints/ migration): baselineMtime is the MAX +// of primary and legacy baseline mtimes (whichever is most recent). +// Per-marker mtime is the MAX across both roots. +// +// .session-baseline is written/touched by em-recall --session-start (called +// from hooks/em-recall-sessionstart.sh). If missing at both roots, the +// carve-out does not apply (conservative — pre-existing sessions before +// this fix shipped). +// +// SubagentStop semantics (P1-1): the same predicate runs for SubagentStop. +// A subagent that wrote files would have caused checkpoint-gate to arm +// .post-checkpoint-required (mtime > baseline), denying the carve-out. A +// subagent that did read-only work satisfies the carve-out — same semantics +// as the parent's no-task-signal turn, which is the desired behavior. +// +// Symlink defense (P2-2): uses lstatSync so a symlink to an old file cannot +// trick the carve-out into firing. ANY symlink — baseline or marker, at +// EITHER root — causes the carve-out to FAIL CLOSED. Same-class symmetry +// per feedback_same_class_completeness.md. Codex round-1 P2 finding +// (episode 20260505-124511-...-845f) reproduced the asymmetry. +// --------------------------------------------------------------------------- +export function _maxMtimeAcrossRoots(repoRoot, basename) { + // Returns { mtime, hadSymlink, anyExisted }. mtime is the max across both + // roots. If either side is a symlink, hadSymlink=true (caller fails closed). + let mtime = -Infinity + let hadSymlink = false + let anyExisted = false + for (const p of [primaryMarkerPath(repoRoot, basename), legacyMarkerPath(repoRoot, basename)]) { + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch {} + } + return { mtime, hadSymlink, anyExisted } +} + +// #268 fix E19: non-strict plan-marker variant for carve-out. Scans BOTH +// legacy `.plan-approval-pending` AND any `.plan-approval-pending.` +// at primary + legacy roots; returns max mtime across the set. +// +// Symmetric with _maxMtimeAcrossRoots (relaxed: lstat errors silently +// skipped). Use this for carve-out (NON-fail-closed) sites; for stop-gate +// fail-closed sites use _maxMtimeAcrossRootsForPlanMarkerStrict above. +export function _maxMtimeAcrossRootsForPlanMarker(repoRoot) { + let mtime = -Infinity + let hadSymlink = false + let anyExisted = false + for (const p of [ + primaryMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), + legacyMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), + ]) { + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch {} + } + const prefix = `${PLAN_MARKER_LEGACY_BASENAME}.` + for (const dir of [path.join(repoRoot, PRIMARY_MARKER_DIR), path.join(repoRoot, LEGACY_MARKER_DIR)]) { + let entries + try { entries = fs.readdirSync(dir) } catch { continue } + for (const name of entries) { + if (!name.startsWith(prefix)) continue + const p = path.join(dir, name) + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) { hadSymlink = true; continue } + anyExisted = true + if (st.mtimeMs > mtime) mtime = st.mtimeMs + } catch {} + } + } + return { mtime, hadSymlink, anyExisted } +} + +// Rank-2: resolve a session-aware marker read. Resolution order: +// 1. /.checkpoints/. (own-session, primary) +// 2. /.claude/. (own-session, legacy root) +// 3. /.checkpoints/ (legacy literal, primary) +// 4. /.claude/ (legacy literal, legacy root) +// +// Returns the first existing path or null. Other sessions' suffixed +// markers are intentionally NOT probed — own-session semantic per +// rank-2 plan §3 trust model. +// +// When sid is null/empty, only steps 3-4 are tried (legacy-literal-only +// fallback for invalid/missing sid). Symlink-aware via fs.existsSync +// (which follows links); callers needing symlink-fail-closed must +// re-check with lstatSync. +export function resolveOwnSessionMarkerRead(repoRoot, legacyBasename, sid) { + if (sid) { + const ownBasename = namespacedMarkerBasenameForSession(legacyBasename, sid) + const ownPrimary = primaryMarkerPath(repoRoot, ownBasename) + if (fs.existsSync(ownPrimary)) return ownPrimary + const ownLegacy = legacyMarkerPath(repoRoot, ownBasename) + if (fs.existsSync(ownLegacy)) return ownLegacy + } + const litPrimary = primaryMarkerPath(repoRoot, legacyBasename) + if (fs.existsSync(litPrimary)) return litPrimary + const litLegacy = legacyMarkerPath(repoRoot, legacyBasename) + if (fs.existsSync(litLegacy)) return litLegacy + return null +} + +export function stopGateCarveOutApplies(repoRoot, sid) { + const base = _maxMtimeAcrossRoots(repoRoot, BASELINE_NAME) + if (base.hadSymlink) return false + if (!base.anyExisted) return false + const baselineMtime = base.mtime + + for (const name of TASK_SIGNAL_MARKERS) { + let m + if (name === PLAN_MARKER_LEGACY_BASENAME) { + // #268 fix E19: plan-marker member glob-expands suffixed forms + // (cross-session — plan-pending deferral is global-by-design). + m = _maxMtimeAcrossRootsForPlanMarker(repoRoot) + } else if (CHECKPOINT_QUARTET.includes(name)) { + // Rank-2 (codex R2 P1-B + R4 ACCEPT): quartet carve-out is + // OWN-SESSION-ONLY — read own `.` + legacy literal, + // NEVER other sessions' suffixed forms. Cross-session safety is + // delegated to SessionStart's force-monotonic baseline probe. + // Strict catch (R2 P2): non-ENOENT errors fail closed. + const strict = _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict( + repoRoot, name, sid) + if (strict.hadOtherError) return false + m = strict + } else { + m = _maxMtimeAcrossRoots(repoRoot, name) + } + // Symlink at either root → fail closed. + if (m.hadSymlink) return false + // Marker absent at both roots → no signal; skip. + if (!m.anyExisted) continue + // Mid-session signal → fail closed. + if (m.mtime > baselineMtime) return false + } + return true +} diff --git a/scripts/lib/stop-gate-helpers.mjs b/scripts/lib/stop-gate-helpers.mjs deleted file mode 100644 index 717ad23..0000000 --- a/scripts/lib/stop-gate-helpers.mjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * stop-gate-helpers.mjs — Pure helpers for em-recall.mjs --gate stop. - * - * Extracted so tests/test-em-strict-lstat.mjs can import the strict-lstat - * helper directly and assert internal state (hadOtherError reached vs. - * tautological BLOCK). Codex round-6 F17. - * - * `_maxMtimeAcrossRootsStrict` — distinguishes ENOENT (marker absent at - * a root → fine to skip) from other lstat errors (EACCES, ENOTDIR, EIO, - * ELOOP — inspection failed → caller must fail closed). For NEW - * fail-closed paths only; the existing `_maxMtimeAcrossRoots` in - * em-recall.mjs retains its relaxed semantic (carve-out callers; FU to - * migrate per scratch/rank1-plan-v7.md FU list). - * - * Codex review trail: rounds 1-7 of rank-1 hook-deadlock plan, episodes - * `...bd6c` → `...afd2` → `...3ad6` → `...5697` → `...fb05` → `...acdc` → - * `...e19a` (ACCEPT-with-FU). - */ - -import fs from 'fs' -import path from 'path' -import { - PRIMARY_MARKER_DIR, - LEGACY_MARKER_DIR, - PLAN_MARKER_LEGACY_BASENAME, - primaryMarkerPath, - legacyMarkerPath, -} from './marker-paths.mjs' - -export function _maxMtimeAcrossRootsStrict(repoRoot, basename) { - let mtime = -Infinity - let hadSymlink = false - let anyExisted = false - let hadOtherError = false - for (const p of [primaryMarkerPath(repoRoot, basename), legacyMarkerPath(repoRoot, basename)]) { - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch (e) { - if (e && e.code !== 'ENOENT') hadOtherError = true - } - } - return { mtime, hadSymlink, anyExisted, hadOtherError } -} - -// #268 fix E19/E20: strict-lstat helper for plan-marker that scans BOTH -// legacy `.plan-approval-pending` AND any `.plan-approval-pending.` -// at primary + legacy roots. Mtime is the MAX across the entire set — -// stop-gate's plan-pending deferral fires if ANY plan-marker (own session -// or other) is active mid-session. -// -// Same fail-closed semantics as the base strict helper: -// - hadSymlink on any matched symlink (caller fails closed) -// - hadOtherError on any non-ENOENT lstat / readdir error -export function _maxMtimeAcrossRootsForPlanMarkerStrict(repoRoot) { - let mtime = -Infinity - let hadSymlink = false - let anyExisted = false - let hadOtherError = false - - // Legacy literal at both roots (same as _maxMtimeAcrossRootsStrict for - // PLAN_MARKER_LEGACY_BASENAME). - for (const p of [ - primaryMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), - legacyMarkerPath(repoRoot, PLAN_MARKER_LEGACY_BASENAME), - ]) { - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch (e) { - if (e && e.code !== 'ENOENT') hadOtherError = true - } - } - - // Glob-expand `.plan-approval-pending.<*>` at both roots. - const prefix = `${PLAN_MARKER_LEGACY_BASENAME}.` - for (const dir of [path.join(repoRoot, PRIMARY_MARKER_DIR), path.join(repoRoot, LEGACY_MARKER_DIR)]) { - let entries - try { - entries = fs.readdirSync(dir) - } catch (e) { - // ENOENT on the dir itself is fine (no markers); other → fail-closed. - if (e && e.code !== 'ENOENT') hadOtherError = true - continue - } - for (const name of entries) { - if (!name.startsWith(prefix)) continue - const p = path.join(dir, name) - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch (e) { - if (e && e.code !== 'ENOENT') hadOtherError = true - } - } - } - - return { mtime, hadSymlink, anyExisted, hadOtherError } -} - -// Rank-2 (PR for checkpoint-quartet) — own-session strict helper for the -// 4 checkpoint quartet markers. Per codex plan-tier R2 P1-B + R4 ACCEPT: -// the quartet carve-out is OWN-SESSION-ONLY, NOT cross-session glob like -// the plan-marker. Cross-session safety for the quartet is delegated to -// SessionStart's force-monotonic baseline probe (em-recall.mjs); the -// stop-gate carve-out at turn-end reads only this session's own marker -// (plus legacy literal during burn-in). -// -// Strict catch (R2 P2): non-ENOENT lstat errors → hadOtherError → caller -// fails closed. Sibling of _maxMtimeAcrossRootsStrict. -// -// @param {string} repoRoot -// @param {string} legacyBasename — one of CHECKPOINT_QUARTET members -// @param {string|null} sid — own session id, or null/empty → legacy-only mode -// @returns {{mtime, hadSymlink, anyExisted, hadOtherError}} -export function _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict( - repoRoot, legacyBasename, sid -) { - let mtime = -Infinity - let hadSymlink = false - let anyExisted = false - let hadOtherError = false - - // Build paths to probe — own-session suffixed AND legacy literal, each - // at both roots. Other sessions' suffixed markers NOT included — - // that's the point of the rank-2 fix. - const paths = [ - primaryMarkerPath(repoRoot, legacyBasename), - legacyMarkerPath(repoRoot, legacyBasename), - ] - if (sid) { - const ownBasename = `${legacyBasename}.${sid}` - paths.push(primaryMarkerPath(repoRoot, ownBasename)) - paths.push(legacyMarkerPath(repoRoot, ownBasename)) - } - - for (const p of paths) { - try { - const st = fs.lstatSync(p) - if (st.isSymbolicLink()) { hadSymlink = true; continue } - anyExisted = true - if (st.mtimeMs > mtime) mtime = st.mtimeMs - } catch (e) { - if (e && e.code !== 'ENOENT') hadOtherError = true - } - } - - return { mtime, hadSymlink, anyExisted, hadOtherError } -} diff --git a/tests/test-em-recall-session-start-early-exit.mjs b/tests/test-em-recall-session-start-early-exit.mjs index 15c9171..e27290b 100644 --- a/tests/test-em-recall-session-start-early-exit.mjs +++ b/tests/test-em-recall-session-start-early-exit.mjs @@ -443,11 +443,13 @@ function buildTempHome(homeRoot) { path.join(SCRIPTS, 'lib', 'marker-paths.mjs'), path.join(installedLib, 'marker-paths.mjs'), ) - // rank-1 plan v7: em-recall now imports stop-gate-helpers.mjs for the - // active-plan exemption. Test fixture must mirror transitive imports. + // RFC-008 P3a: em-recall imports marker-state.mjs (relocated from + // stop-gate-helpers.mjs) for the active-plan exemption. Test fixture must + // mirror transitive imports (marker-state imports the already-copied + // marker-paths.mjs). fs.copyFileSync( - path.join(SCRIPTS, 'lib', 'stop-gate-helpers.mjs'), - path.join(installedLib, 'stop-gate-helpers.mjs'), + path.join(SCRIPTS, 'lib', 'marker-state.mjs'), + path.join(installedLib, 'marker-state.mjs'), ) // 2026-05-18 concurrent-session fix: em-recall now imports session-id.mjs // for the --session-id flag validation. Mirror per diff --git a/tests/test-em-strict-lstat.mjs b/tests/test-em-strict-lstat.mjs index b41b790..04ddc77 100644 --- a/tests/test-em-strict-lstat.mjs +++ b/tests/test-em-strict-lstat.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // test-em-strict-lstat.mjs — Level-2 in-process unit tests for -// _maxMtimeAcrossRootsStrict (scripts/lib/stop-gate-helpers.mjs). +// _maxMtimeAcrossRootsStrict (scripts/lib/marker-state.mjs — relocated from +// stop-gate-helpers.mjs in RFC-008 P3a). // // Codex round-6 F17: prove the strict-error PATH is reached (not just // a tautological BLOCK from a downstream effect). Imports the helper @@ -18,7 +19,7 @@ import fs from 'fs' import os from 'os' import path from 'path' import { execSync } from 'child_process' -import { _maxMtimeAcrossRootsStrict } from '../scripts/lib/stop-gate-helpers.mjs' +import { _maxMtimeAcrossRootsStrict } from '../scripts/lib/marker-state.mjs' let pass = 0 let fail = 0 diff --git a/tests/test-issue-146.mjs b/tests/test-issue-146.mjs index d061624..9a3edca 100644 --- a/tests/test-issue-146.mjs +++ b/tests/test-issue-146.mjs @@ -546,7 +546,9 @@ function mkE2EHome() { // omit it and em-recall fails to load (same fix as test-stop-gate.sh's // mk_fake_home). // 2026-05-18 concurrent-session fix: em-recall now imports session-id.mjs. - for (const lib of ['local-dir.mjs', 'marker-paths.mjs', 'stop-gate-helpers.mjs', 'session-id.mjs']) { + // RFC-008 P3a: em-recall imports marker-state.mjs (relocated from + // stop-gate-helpers.mjs); marker-state imports the already-copied marker-paths.mjs. + for (const lib of ['local-dir.mjs', 'marker-paths.mjs', 'marker-state.mjs', 'session-id.mjs']) { const libSrc = path.join(REPO_ROOT, 'scripts', 'lib', lib) fs.copyFileSync(libSrc, path.join(scripts, 'lib', lib)) } diff --git a/tests/test-marker-state.mjs b/tests/test-marker-state.mjs new file mode 100644 index 0000000..2273caf --- /dev/null +++ b/tests/test-marker-state.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// test-marker-state.mjs — unit tests for scripts/lib/marker-state.mjs +// (RFC-008 P3a). These carve-out helpers were previously module-private inside +// em-recall.mjs (the relaxed _maxMtimeAcrossRoots* helpers, resolveOwnSessionMarkerRead, +// stopGateCarveOutApplies) or lived in stop-gate-helpers.mjs (the 3 strict +// helpers, covered additionally by test-em-strict-lstat.mjs). The move into the +// enforcement-owned marker-state module is a PURE EXTRACTION; these tests pin the +// semantics so any future behavior drift is caught directly at the lib boundary. +// +// Coverage includes the negative-scenario-planner's P3a-required cases: +// - symlink-fail-closed (carve-out returns false on a symlinked baseline/marker) +// - dual-root active-legacy (active .claude/ marker + absent primary → detected) +// - null-sid parity (resolveOwnSessionMarkerRead degrades to legacy literal) + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { + _maxMtimeAcrossRoots, + _maxMtimeAcrossRootsForPlanMarker, + _maxMtimeAcrossRootsForPlanMarkerStrict, + _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict, + resolveOwnSessionMarkerRead, + stopGateCarveOutApplies, +} from '../scripts/lib/marker-state.mjs' +import { + PRIMARY_MARKER_DIR, + LEGACY_MARKER_DIR, + BASELINE_NAME, + PLAN_MARKER_LEGACY_BASENAME, + primaryMarkerPath, + legacyMarkerPath, +} from '../scripts/lib/marker-paths.mjs' + +let pass = 0 +let fail = 0 +const failures = [] + +function ok(name) { pass++; console.log(` ✓ ${name}`) } +function bad(name, detail) { + fail++; failures.push(`${name}: ${detail}`) + console.log(` ✗ ${name}: ${detail}`) +} +function eq(name, actual, expected) { + if (actual === expected) ok(name) + else bad(name, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) +} + +// Fresh isolated repo root per test, with both marker roots present. +function mkRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'marker-state-')) + fs.mkdirSync(path.join(repo, PRIMARY_MARKER_DIR), { recursive: true }) + fs.mkdirSync(path.join(repo, LEGACY_MARKER_DIR), { recursive: true }) + return repo +} +// Write a marker file with an explicit mtime (epoch seconds). +function writeMarker(p, mtimeSec) { + fs.writeFileSync(p, '') + if (mtimeSec !== undefined) fs.utimesSync(p, mtimeSec, mtimeSec) +} + +const SID = 'abc-123' + +// --------------------------------------------------------------------------- +// _maxMtimeAcrossRoots (relaxed) +// --------------------------------------------------------------------------- +{ + const repo = mkRepo() + const r = _maxMtimeAcrossRoots(repo, BASELINE_NAME) + eq('relaxed: absent both roots → anyExisted=false', r.anyExisted, false) + eq('relaxed: absent both roots → hadSymlink=false', r.hadSymlink, false) +} +{ + const repo = mkRepo() + // Primary older (100), legacy newer (200) → mtime is the MAX (200s = 200000ms). + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 100) + writeMarker(legacyMarkerPath(repo, BASELINE_NAME), 200) + const r = _maxMtimeAcrossRoots(repo, BASELINE_NAME) + eq('relaxed: max across roots → anyExisted=true', r.anyExisted, true) + eq('relaxed: max across roots → mtime = newer (legacy 200)', Math.round(r.mtime), 200000) +} +{ + const repo = mkRepo() + const target = primaryMarkerPath(repo, BASELINE_NAME) + fs.symlinkSync('/nonexistent-target', target) + const r = _maxMtimeAcrossRoots(repo, BASELINE_NAME) + eq('relaxed: symlink primary → hadSymlink=true', r.hadSymlink, true) + eq('relaxed: symlink primary → anyExisted=false', r.anyExisted, false) +} + +// --------------------------------------------------------------------------- +// _maxMtimeAcrossRootsForPlanMarker (relaxed glob of legacy + suffixed) +// --------------------------------------------------------------------------- +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, PLAN_MARKER_LEGACY_BASENAME), 100) // legacy literal + writeMarker(primaryMarkerPath(repo, `${PLAN_MARKER_LEGACY_BASENAME}.${SID}`), 300) // suffixed, newer + const r = _maxMtimeAcrossRootsForPlanMarker(repo) + eq('plan-marker glob: anyExisted=true', r.anyExisted, true) + eq('plan-marker glob: mtime = newest suffixed (300)', Math.round(r.mtime), 300000) +} +{ + const repo = mkRepo() + fs.symlinkSync('/nonexistent', primaryMarkerPath(repo, `${PLAN_MARKER_LEGACY_BASENAME}.${SID}`)) + const r = _maxMtimeAcrossRootsForPlanMarker(repo) + eq('plan-marker glob: symlinked suffixed form → hadSymlink=true', r.hadSymlink, true) +} + +// --------------------------------------------------------------------------- +// _maxMtimeAcrossRootsForPlanMarkerStrict (fail-closed) +// --------------------------------------------------------------------------- +{ + const repo = mkRepo() + fs.symlinkSync('/nonexistent', primaryMarkerPath(repo, PLAN_MARKER_LEGACY_BASENAME)) + const r = _maxMtimeAcrossRootsForPlanMarkerStrict(repo) + eq('plan-marker strict: symlink → hadSymlink=true', r.hadSymlink, true) + eq('plan-marker strict: symlink → hadOtherError=false', r.hadOtherError, false) +} + +// --------------------------------------------------------------------------- +// _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict +// --------------------------------------------------------------------------- +{ + // ENOTDIR: make the PRIMARY marker root a regular file (not a dir) so lstat of + // any marker path under it errs non-ENOENT (ENOTDIR) → strict helper fails closed. + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'marker-state-')) + fs.writeFileSync(path.join(repo, PRIMARY_MARKER_DIR), '') // .checkpoints is a FILE + fs.mkdirSync(path.join(repo, LEGACY_MARKER_DIR), { recursive: true }) + const r = _maxMtimeAcrossRootsForCheckpointMarkerOwnSessionStrict(repo, '.checkpoint-required', SID) + eq('quartet strict: ENOTDIR under non-dir primary root → hadOtherError=true', r.hadOtherError, true) +} + +// --------------------------------------------------------------------------- +// resolveOwnSessionMarkerRead +// --------------------------------------------------------------------------- +{ + const repo = mkRepo() + const own = primaryMarkerPath(repo, `.checkpoint-required.${SID}`) + writeMarker(own, 100) + eq('resolve: own-session primary preferred', resolveOwnSessionMarkerRead(repo, '.checkpoint-required', SID), own) +} +{ + const repo = mkRepo() + const lit = primaryMarkerPath(repo, '.checkpoint-required') + writeMarker(lit, 100) + eq('resolve: falls back to legacy literal when own absent', resolveOwnSessionMarkerRead(repo, '.checkpoint-required', SID), lit) +} +{ + const repo = mkRepo() + const lit = primaryMarkerPath(repo, '.checkpoint-required') + writeMarker(lit, 100) + // null sid → own-session steps skipped → returns literal (planner null-sid parity) + eq('resolve: null sid degrades to legacy literal', resolveOwnSessionMarkerRead(repo, '.checkpoint-required', null), lit) +} +{ + const repo = mkRepo() + eq('resolve: nothing exists → null', resolveOwnSessionMarkerRead(repo, '.checkpoint-required', SID), null) +} + +// --------------------------------------------------------------------------- +// stopGateCarveOutApplies +// --------------------------------------------------------------------------- +{ + const repo = mkRepo() + // No baseline → conservative false. + eq('carve-out: no baseline → false', stopGateCarveOutApplies(repo, SID), false) +} +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 1000) + // All task-signal markers absent → carve-out applies. + eq('carve-out: baseline present, no markers → true', stopGateCarveOutApplies(repo, SID), true) +} +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 1000) + // Quartet marker (own-session) NEWER than baseline → mid-session signal → false. + writeMarker(primaryMarkerPath(repo, `.checkpoint-required.${SID}`), 2000) + eq('carve-out: own-session quartet newer than baseline → false', stopGateCarveOutApplies(repo, SID), false) +} +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 1000) + // Marker OLDER than baseline → stale → carve-out still applies. + writeMarker(primaryMarkerPath(repo, `.checkpoint-required.${SID}`), 500) + eq('carve-out: marker older than baseline → true', stopGateCarveOutApplies(repo, SID), true) +} +{ + // Planner dual-root case: baseline in PRIMARY, active marker only in LEGACY + // (.claude/), absent in primary → must still be detected as mid-session. + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 1000) + writeMarker(legacyMarkerPath(repo, `.checkpoint-required.${SID}`), 2000) + eq('carve-out: dual-root active-legacy quartet newer → false', stopGateCarveOutApplies(repo, SID), false) +} +{ + // Symlink-fail-closed: symlinked baseline → carve-out false. + const repo = mkRepo() + fs.symlinkSync('/nonexistent', primaryMarkerPath(repo, BASELINE_NAME)) + eq('carve-out: symlinked baseline → false (fail-closed)', stopGateCarveOutApplies(repo, SID), false) +} +{ + // Symlink-fail-closed on a task-signal marker (plan-marker) → false. + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 1000) + fs.symlinkSync('/nonexistent', primaryMarkerPath(repo, PLAN_MARKER_LEGACY_BASENAME)) + eq('carve-out: symlinked plan-marker → false (fail-closed)', stopGateCarveOutApplies(repo, SID), false) +} + +// --------------------------------------------------------------------------- +console.log(`\n${pass} passed, ${fail} failed`) +if (fail > 0) { + console.log('\nFailures:') + for (const f of failures) console.log(` - ${f}`) + process.exit(1) +} diff --git a/tests/test-stop-gate.sh b/tests/test-stop-gate.sh index 1d94e54..c23b9db 100755 --- a/tests/test-stop-gate.sh +++ b/tests/test-stop-gate.sh @@ -77,9 +77,11 @@ mk_fake_home() { # marker-paths.mjs; without it the module fails to load and the hook # falls back to the canned em-recall-non-zero error message. cp "$REPO_ROOT/scripts/lib/marker-paths.mjs" "$fake_home/.episodic-memory/scripts/lib/marker-paths.mjs" - # rank-1 plan v7 (2026-05-12): em-recall also imports stop-gate-helpers.mjs - # for the active-plan exemption (_maxMtimeAcrossRootsStrict). - cp "$REPO_ROOT/scripts/lib/stop-gate-helpers.mjs" "$fake_home/.episodic-memory/scripts/lib/stop-gate-helpers.mjs" + # RFC-008 P3a (2026-06-15): em-recall imports marker-state.mjs (relocated + # from stop-gate-helpers.mjs) for the active-plan exemption + # (_maxMtimeAcrossRootsStrict). marker-state imports the already-copied + # marker-paths.mjs. + cp "$REPO_ROOT/scripts/lib/marker-state.mjs" "$fake_home/.episodic-memory/scripts/lib/marker-state.mjs" # 2026-05-18 concurrent-session fix: em-recall imports session-id.mjs for # the --session-id flag (codex R1 P1.2; logging-only in v6 sweep). cp "$REPO_ROOT/scripts/lib/session-id.mjs" "$fake_home/.episodic-memory/scripts/lib/session-id.mjs"