diff --git a/.github/workflows/plan-marker-validate.yml b/.github/workflows/plan-marker-validate.yml index 5b94952..eed2b3c 100644 --- a/.github/workflows/plan-marker-validate.yml +++ b/.github/workflows/plan-marker-validate.yml @@ -51,6 +51,12 @@ jobs: - name: Run em-recall-sessionstart hook-wrapper integration (cwd + stdin sid binding) run: bash tests/test-em-recall-sessionstart-hook-binding.sh + - name: Run em-recall-sessionstart hook regression (incl. F1 freshness-probe layout fix) + run: bash tests/test-em-recall-sessionstart.sh + + - name: Run preflight-marker reapers (F4 SessionEnd reap + SessionStart 7d orphan sweep) + run: node tests/test-preflight-marker-reapers.mjs + - name: Run no-legacy-plan-marker-writes detector (Rule 13) run: node tests/test-no-legacy-plan-marker-writes.mjs diff --git a/plugins/claude-code/hooks/em-recall-sessionstart.sh b/plugins/claude-code/hooks/em-recall-sessionstart.sh index aa57b03..9c436d6 100755 --- a/plugins/claude-code/hooks/em-recall-sessionstart.sh +++ b/plugins/claude-code/hooks/em-recall-sessionstart.sh @@ -59,7 +59,12 @@ warn_hook_freshness() { fi [ -n "$source_repo" ] || return 0 - if [ ! -d "$source_repo/hooks" ]; then + # Probe only the repo root, not a fixed subdirectory layout. The old + # `$source_repo/hooks` probe broke when PR #373 moved hooks to + # plugins/claude-code/hooks/ — every SessionStart printed a false + # "source repo unavailable" warning. The per-file loop below already + # classifies missing/relocated sources (missing_source bucket). + if [ ! -d "$source_repo" ]; then echo "episodic-memory: hook freshness warning: source repo unavailable: $source_repo" echo "episodic-memory: installed Claude hooks may be stale; re-run install.mjs --install-hooks from the current episodic-memory repo." return 0 diff --git a/plugins/claude-code/hooks/session-handoff-prompt.sh b/plugins/claude-code/hooks/session-handoff-prompt.sh index cc33558..9cbdd49 100755 --- a/plugins/claude-code/hooks/session-handoff-prompt.sh +++ b/plugins/claude-code/hooks/session-handoff-prompt.sh @@ -9,7 +9,7 @@ # - Resolves repo_root via git common-dir, normalizing linked-worktree → MAIN. # - Resolves memory_root with BOUNDED candidate set (sanitization variants of # resolved repo_root ONLY — no ~/.claude/projects/* scan; codex F9). -# - Emits explicit absolute paths for the 8 always-tier discipline files +# - Emits explicit absolute paths for the always-tier discipline files # (codex F11; replaces "batch-Read the feedback files under MEMORY.md's … # anchors" prose which loaded 34+ files / ~47k tokens / ~10% of context). # - Emits mechanical `cd && node /scripts/em-search.mjs …` @@ -26,6 +26,11 @@ # r4 …68d0→…87ac HOLD (F12 matrix vary stdin.cwd, F13 fail-safe tightening) # r5 …5c73→…0355 ACCEPT-with-FU (no new architectural class; #19 trigger) # +# 2026-06-12 (checkpoint-hygiene C2/F3): backported the installed runtime — +# 6-file always-tier list (2026-05-16 demotions) + condensed Q1/Q2 directive — +# which had drifted ahead of this source; file is now tracked in HOOK_SPECS / +# the freshness manifest so future drift is reported at SessionStart. +# # Composes with: # - hooks/lib/repo-root.sh (sourced for resolve_repo_root canonical helper) # - feedback_project_root_binding_audit.md discipline #20 @@ -144,7 +149,11 @@ else fi # --------------------------------------------------------------------------- -# Always-tier list (8 files, codex r1 F2 added canonical-agent-dispatch). +# Always-tier list (6 files; demotions 2026-05-16): +# - feedback_send_grep_artifact.md → lazy-tier (content trigger: PII / +# sanitization keywords via MEMORY.md Trigger-phrase index) +# - feedback_three_state_review_verdict.md → lazy-tier (loads when +# "verdict" / "ACCEPT" / "HOLD" / "REJECT" / "approving" keywords fire) # These rules fire on EVERY claim OR on short/no-keyword prompts; lazy-loading # them would defeat their self-trigger contract (catch-22 from plan v1). # --------------------------------------------------------------------------- @@ -153,8 +162,6 @@ ALWAYS_TIER=( "feedback_verify_by_artifact.md" "feedback_self_trigger_artifact_mode.md" "feedback_per_prompt_rule_preflight.md" - "feedback_send_grep_artifact.md" - "feedback_three_state_review_verdict.md" "feedback_bp1_step9_filing_trigger.md" "feedback_canonical_agent_dispatch_trigger.md" ) @@ -199,42 +206,28 @@ if [ -f "${HANDOFF}" ]; then || stat -c '%y' "${HANDOFF}" 2>/dev/null | cut -c1-16 \ || echo unknown)" - DIRECTIVE="BLOCKING DIRECTIVE: This session has TWO pending y/n questions you MUST ask in order before responding to the user's prompt. + DIRECTIVE="BLOCKING: Ask both y/n questions before responding. No preamble or tool calls between them. -Phase 1 — Your FIRST message this session must be exactly: +Q1 (verbatim): Load session_handoff.md from ${MTIME}? (y/n) -Phase 2 — After Phase 1 is answered, your NEXT message must be exactly: +Q2 (verbatim, after Q1 is answered): Load discipline + toolkit + recent lessons? (y/n) -Q1 answer must be remembered across Phase 2; do NOT drop it. -No preamble, no other content, no tool calls between Phase 1 and Phase 2. - -After BOTH answers are collected, process them in this order: - 1. If Phase 1 answer = y: Read ${HANDOFF} - 2. If Phase 2 answer = y: batch-Read these 8 always-tier discipline files (explicit absolute paths): -${_paths} Then run the lessons search command: - ${EM_SEARCH_CMD} - (Lazy-tier discipline files load on demand via Read or trigger-phrase classifier — see MEMORY.md tier-3 trigger-phrase index. This always-tier load replaces the prior 34+ file batch-Read.) - 3. Then address the user's original prompt. +Remember Q1's answer across Q2; do NOT drop it. -Do not skip this even if the user's first message asks something else; ask Phase 1 first, then Phase 2, then address their request." +After both answers, process in order: + 1. If Q1=y: Read ${HANDOFF} + 2. If Q2=y: batch-Read these 6 absolute paths, then run the em-search command: +${_paths} ${EM_SEARCH_CMD} + 3. Then address the user's prompt." else - DIRECTIVE="BLOCKING DIRECTIVE: This session has ONE pending y/n question you MUST ask before responding to the user's prompt. - -Your FIRST message this session must be exactly: + DIRECTIVE="BLOCKING: Ask this y/n question (verbatim) before responding, no preamble or tool calls: Load discipline + toolkit + recent lessons? (y/n) -No preamble, no other content, no tool calls. - -After the answer is collected, process it: - 1. If answer = y: batch-Read these 8 always-tier discipline files (explicit absolute paths): -${_paths} Then run the lessons search command: - ${EM_SEARCH_CMD} - (Lazy-tier discipline files load on demand via Read or trigger-phrase classifier — see MEMORY.md tier-3 trigger-phrase index. This always-tier load replaces the prior 34+ file batch-Read.) - 2. Then address the user's original prompt. - -Do not skip this even if the user's first message asks something else; ask the y/n question first, then address their request after their answer." +If y: batch-Read these 6 absolute paths, then run the em-search command: +${_paths} ${EM_SEARCH_CMD} +Then address the user's prompt." fi jq -n --arg ctx "${DIRECTIVE}" '{ diff --git a/scripts/classifier-marker.mjs b/scripts/classifier-marker.mjs index 442c247..b98095b 100755 --- a/scripts/classifier-marker.mjs +++ b/scripts/classifier-marker.mjs @@ -542,7 +542,12 @@ function vacuumMarkers(projectRootCanon, maxAgeDays) { const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000 let removed = 0, scanned = 0 for (const entry of fs.readdirSync(classifyDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.json')) continue + // Checkpoint-hygiene F5: vacuum also reaps aged one-shot *.cmd staging + // files (the deny-hint flow stages them here; consume-unlink in --write + // handles the happy path, vacuum is the backstop for aborted flows). + // Verdict *.json cache semantics unchanged. + const reapable = entry.name.endsWith('.json') || entry.name.endsWith('.cmd') + if (!entry.isFile() || !reapable) continue scanned++ const p = path.join(classifyDir, entry.name) try { @@ -676,13 +681,45 @@ function mainWrite(argv) { classified_by: 'agent_self' } const written = writeMarker(classifyDir, sha, payload) + + // Checkpoint-hygiene F5: consume the one-shot --command-file staging file + // once a verdict is on file. Keyed on the common exit-0 epilogue so EVERY + // success status ('written' AND 'noop_same_tuple') consumes — a same-tuple + // re-write must not strand its staging file. Unlink ONLY when the + // canonicalized file sits inside the validated classifyDir (single + // canonical pass via realpath, never raw-string prefix on the input) and + // lstat says regular file (symlinks are never followed or removed). + // Staging files outside classifyDir belong to the agent and stay put. + const cmdFileArg = flag(argv, '--command-file') + let commandFileConsumed = false + if (cmdFileArg !== undefined) { + try { + // lstat the ARGV path BEFORE any realpath: a symlink staging file is + // never consumed, even when its canonical target lands inside + // classifyDir. Codex code-review R1 P1 (episode 20260612-101323-…-a15e) + // reproduced the realpath-first ordering deleting an in-dir symlink's + // in-dir TARGET — a wrong-family deletion inside the correct root. + const argvPath = path.resolve(cmdFileArg) + const argvSt = fs.lstatSync(argvPath) + if (argvSt.isFile()) { + const realCf = realpathOrSame(argvPath) + const st = fs.lstatSync(realCf) + if (st.isFile() && isUnder(realCf, classifyDir) && realCf !== written.file) { + fs.unlinkSync(realCf) + commandFileConsumed = true + } + } + } catch { /* best-effort: a vanished staging file never fails the write */ } + } + emit({ status: written.status, file: written.file, label, cache_key: sha, session_id: sessionId, - project_root_used: projectRoot + project_root_used: projectRoot, + command_file_consumed: commandFileConsumed }) process.exit(0) } diff --git a/scripts/em-recall.mjs b/scripts/em-recall.mjs index 62553d0..0a9d5c1 100644 --- a/scripts/em-recall.mjs +++ b/scripts/em-recall.mjs @@ -34,6 +34,8 @@ import { bothMarkerPaths, namespacedMarkerBasenameForSession, CHECKPOINT_QUARTET, + preflightMarkerSuffixedBasenameMatches, + lastUserPromptBasenameMatches, } from './lib/marker-paths.mjs' import { _maxMtimeAcrossRootsStrict, @@ -748,6 +750,44 @@ if (sessionStartFlag) { } } + // --------------------------------------------------------------------- + // Preflight-family orphan sweep (checkpoint-hygiene F4, closes the #283 + // SessionStart half). `.preflight-done.` and + // `.last-user-prompt..json` are written by preflight-prompt-helper.sh + // every session; SessionEnd reaps the own-session pair, this sweep reaps + // crashed-session orphans. + // + // Containment + safety: + // - readdir of the PRIMARY marker dir only (families never had legacy + // .claude/ forms); basenames carry no path separators. + // - suffixED-only matchers — the legacy suffix-less `.preflight-done` + // is burn-in/F7 scope and is preserved. + // - 7-day mtime guard: a live concurrent session's markers are days, + // not weeks, old. Worst case (week-idle still-open session) the + // preflight gate re-prompts once — fail-closed in the safe direction. + // - lstat per entry; symlinks never followed or unlinked (mirrors the + // classifier-marker vacuum contract). + // --------------------------------------------------------------------- + const PREFLIGHT_ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + const sweepCutoff = Date.now() - PREFLIGHT_ORPHAN_MAX_AGE_MS + const primaryDir = path.join(REPO_ROOT, PRIMARY_MARKER_DIR) + let sweepEntries = [] + try { sweepEntries = fs.readdirSync(primaryDir) } catch { sweepEntries = [] } + for (const name of sweepEntries) { + if (!preflightMarkerSuffixedBasenameMatches(name) && !lastUserPromptBasenameMatches(name)) continue + const p = path.join(primaryDir, name) + try { + const st = fs.lstatSync(p) + if (st.isSymbolicLink()) continue + if (!st.isFile()) continue + if (st.mtimeMs < sweepCutoff) fs.unlinkSync(p) + } catch (e) { + if (e.code !== 'ENOENT') { + process.stderr.write(`em-recall: preflight-orphan-sweep skipped ${p}: ${e.code || e.message}\n`) + } + } + } + } catch { // Best-effort: a failure here leaves the legacy-plan-marker sweep // inactive; SessionEnd hook still owns own-session cleanup. diff --git a/scripts/em-session-end-prompt.mjs b/scripts/em-session-end-prompt.mjs index 60adc3f..c4eab69 100644 --- a/scripts/em-session-end-prompt.mjs +++ b/scripts/em-session-end-prompt.mjs @@ -26,6 +26,8 @@ import { namespacedMarkerBasenameForSession, CHECKPOINT_QUARTET, PLAN_APPROVED_LEGACY_BASENAME, + preflightMarkerBasenameForSession, + lastUserPromptBasenameForSession, } from './lib/marker-paths.mjs' import { validateSessionId } from './lib/session-id.mjs' @@ -165,6 +167,21 @@ for (const marker of ALL_MIGRATED_MARKERS) { } } +// Checkpoint-hygiene F4 (closes the #283 SessionEnd half): own-session reap +// of the per-session preflight families written by preflight-prompt-helper.sh +// every session. Primary dir ONLY — these families never had legacy .claude/ +// forms. Own-session only (mirrors the quartet's cross-session contract); +// crashed-session orphans are reaped by the em-recall SessionStart sweep +// (7-day mtime guard). Invalid/missing sid → skip; the orphan sweep handles it. +if (validateSessionId(sessionEndSid)) { + for (const basename of [ + preflightMarkerBasenameForSession(sessionEndSid), + lastUserPromptBasenameForSession(sessionEndSid), + ]) { + try { fs.unlinkSync(primaryMarkerPath(repoRoot, basename)) } catch {} + } +} + const patterns = loadPatternsIndex(repoRoot) const knownPatterns = patterns.map(p => ({ diff --git a/scripts/lib/install-manifest.mjs b/scripts/lib/install-manifest.mjs index c579c6e..bad2747 100755 --- a/scripts/lib/install-manifest.mjs +++ b/scripts/lib/install-manifest.mjs @@ -82,6 +82,16 @@ export const HOOK_SPECS = [ file: 'preflight-prompt-helper.sh', event: 'UserPromptSubmit', timeout: 5 + }, + // session-handoff-prompt.sh: SessionStart two-phase handoff/discipline + // directive (rank-10 slice). Was installed + registered manually but never + // tracked here, so the installed copy drifted ahead of the repo source + // unreported (checkpoint-hygiene F3, diagnosis 20260611-234742). Timeout 5 + // matches the pre-existing manual registration in settings.json. + { + file: 'session-handoff-prompt.sh', + event: 'SessionStart', + timeout: 5 } ] diff --git a/scripts/lib/marker-paths.mjs b/scripts/lib/marker-paths.mjs index 5fc3633..deb1b15 100644 --- a/scripts/lib/marker-paths.mjs +++ b/scripts/lib/marker-paths.mjs @@ -319,6 +319,62 @@ export function preflightMarkerBasenameForSession(sid) { return PREFLIGHT_MARKER_BASENAME_TEMPLATE.replace('{sid}', sid) } +/** + * Suffixed-ONLY preflight-marker match (checkpoint-hygiene F4, closes part + * of #283). Accepts `.preflight-done.`; rejects the legacy suffix-less + * `.preflight-done` — its lifecycle belongs to the burn-in cutover (F7), + * not the per-session orphan sweep. Reuses PREFLIGHT_MARKER_BASENAME_RE + * (suffix capture group must be non-null) so the two matchers cannot drift. + * + * @param {string} basename + * @returns {boolean} + */ +export function preflightMarkerSuffixedBasenameMatches(basename) { + if (typeof basename !== 'string') return false + const m = PREFLIGHT_MARKER_BASENAME_RE.exec(basename) + return !!(m && m[1]) +} + +// --------------------------------------------------------------------------- +// Checkpoint-hygiene F4 — per-session .last-user-prompt sidecar contract. +// +// Written by preflight-prompt-helper.sh (UserPromptSubmit) as +// `.last-user-prompt..json`; consumed by preflight-gate.sh for true +// prompt binding (#238 PR1 FU-C2). Suffix-MANDATORY by construction — there +// was never a suffix-less legacy form, so the sweep matcher has no +// burn-in carve-out. +// --------------------------------------------------------------------------- + +export const LAST_USER_PROMPT_BASENAME_TEMPLATE = '.last-user-prompt.{sid}.json' +export const LAST_USER_PROMPT_BASENAME_RE = /^\.last-user-prompt\.[A-Za-z0-9_-]{1,128}\.json$/ + +/** + * Strict match for per-session last-user-prompt sidecar basenames. Accepts ONLY: + * .last-user-prompt..json (sid matches char-class + length) + * Rejects: + * .last-user-prompt.json (no sid) + * .last-user-prompt. (missing .json) + * .last-user-prompt.a/b.json (slash in sid) + * .last-user-prompt.<129-char>.json (oversize sid) + * + * @param {string} basename + * @returns {boolean} + */ +export function lastUserPromptBasenameMatches(basename) { + return typeof basename === 'string' && LAST_USER_PROMPT_BASENAME_RE.test(basename) +} + +/** + * Compose the per-session last-user-prompt basename for a given session id. + * Caller MUST validateSessionId(sid) first; this helper does not re-validate. + * + * @param {string} sid — valid session-id + * @returns {string} '.last-user-prompt..json' + */ +export function lastUserPromptBasenameForSession(sid) { + return LAST_USER_PROMPT_BASENAME_TEMPLATE.replace('{sid}', sid) +} + // --------------------------------------------------------------------------- // Rank-2 (PR for checkpoint-quartet) — generic per-session marker contract. // diff --git a/tests/test-classifier-marker.mjs b/tests/test-classifier-marker.mjs index 970bcc6..3b8075e 100644 --- a/tests/test-classifier-marker.mjs +++ b/tests/test-classifier-marker.mjs @@ -633,6 +633,150 @@ test('§CF4 --command-file write then inline --read hit (write path honors --com assert.strictEqual(r.label, 'read_only') }) +// ----- Checkpoint-hygiene F5: --command-file consume-unlink + vacuum .cmd ----- + +test('§CF5 in-dir --command-file consumed on written; verdict intact', () => { + const repo = mkrepo('cf5') + const sid = 's_cf5_' + crypto.randomBytes(4).toString('hex') + const classifyDir = path.join(repo, '.checkpoints', 'classify') + fs.mkdirSync(classifyDir, { recursive: true }) + const cmdFile = path.join(classifyDir, 'pending-cf5.cmd') + fs.writeFileSync(cmdFile, 'node ./scripts/foo.mjs') + + const w = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', cmdFile, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'staged in classify dir' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(w.status, 0, `write failed: ${w.stderr}`) + const wj = parseStdout(w) + assert.strictEqual(wj.status, 'written') + assert.strictEqual(wj.command_file_consumed, true, 'in-dir staging file must be consumed') + assert.ok(!fs.existsSync(cmdFile), 'staging file must be unlinked') + assert.ok(fs.existsSync(wj.file), 'verdict marker must survive the consume') +}) + +test('§CF6 noop_same_tuple ALSO consumes (second same-tuple write, fresh staging file)', () => { + const repo = mkrepo('cf6') + const sid = 's_cf6_' + crypto.randomBytes(4).toString('hex') + const classifyDir = path.join(repo, '.checkpoints', 'classify') + fs.mkdirSync(classifyDir, { recursive: true }) + const cmd = 'node ./scripts/foo.mjs' + + const f1 = path.join(classifyDir, 'pending-cf6-first.cmd') + fs.writeFileSync(f1, cmd) + const w1 = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', f1, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'first' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(parseStdout(w1).status, 'written') + + const f2 = path.join(classifyDir, 'pending-cf6-second.cmd') + fs.writeFileSync(f2, cmd) + const w2 = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', f2, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'second' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(w2.status, 0, `same-tuple rewrite failed: ${w2.stderr}`) + const j2 = parseStdout(w2) + assert.strictEqual(j2.status, 'noop_same_tuple') + assert.strictEqual(j2.command_file_consumed, true, 'noop_same_tuple must also consume') + assert.ok(!fs.existsSync(f2), 'second staging file must be unlinked on noop_same_tuple') +}) + +test('§CF7 out-of-dir --command-file preserved (verdict still written)', () => { + const repo = mkrepo('cf7') + const sid = 's_cf7_' + crypto.randomBytes(4).toString('hex') + const cmdFile = path.join(repo, 'my-staging.cmd') + fs.writeFileSync(cmdFile, 'node ./scripts/foo.mjs') + + const w = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', cmdFile, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'staged outside classify dir' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(w.status, 0, `write failed: ${w.stderr}`) + const wj = parseStdout(w) + assert.strictEqual(wj.status, 'written') + assert.strictEqual(wj.command_file_consumed, false, 'out-of-dir file must NOT be consumed') + assert.ok(fs.existsSync(cmdFile), 'out-of-dir staging file must be preserved') +}) + +test('§CF8 symlinked --command-file → never unlinked (link + target preserved)', () => { + const repo = mkrepo('cf8') + const sid = 's_cf8_' + crypto.randomBytes(4).toString('hex') + const classifyDir = path.join(repo, '.checkpoints', 'classify') + fs.mkdirSync(classifyDir, { recursive: true }) + const external = mktmp('cf8-target') + const target = path.join(external, 'real-cmd.txt') + fs.writeFileSync(target, 'node ./scripts/foo.mjs') + const link = path.join(classifyDir, 'pending-cf8.cmd') + fs.symlinkSync(target, link) + + const w = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', link, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'symlinked staging' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(w.status, 0, `write failed: ${w.stderr}`) + assert.strictEqual(parseStdout(w).command_file_consumed, false, + 'symlinked staging file must not be consumed (canonical target is out-of-dir)') + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'symlink must survive') + assert.ok(fs.existsSync(target), 'link target must survive') +}) + +test('§CF10 (codex CR R1 P1) in-dir symlink → in-dir target: NEITHER consumed', () => { + const repo = mkrepo('cf10') + const sid = 's_cf10_' + crypto.randomBytes(4).toString('hex') + const classifyDir = path.join(repo, '.checkpoints', 'classify') + fs.mkdirSync(classifyDir, { recursive: true }) + // Another session's staging file, IN-DIR. + const target = path.join(classifyDir, 'other-session.cmd') + fs.writeFileSync(target, 'node ./scripts/foo.mjs') + // This session's "staging file" is an in-dir symlink to it. + const link = path.join(classifyDir, 'current-session.cmd') + fs.symlinkSync(target, link) + + const w = runHelper(['--write', + '--project-root', repo, '--caller-cwd', repo, + '--command-file', link, '--session-id', sid, + '--label', 'read_only', '--confidence', '0.9', '--reason', 'in-dir symlink staging' + ], { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(w.status, 0, `write failed: ${w.stderr}`) + assert.strictEqual(parseStdout(w).command_file_consumed, false, + 'symlink staging file must not consume — even with an in-dir canonical target') + assert.ok(fs.existsSync(target), 'in-dir TARGET must survive (wrong-family deletion)') + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'symlink must survive') +}) + +test('§CF9 vacuum reaps aged *.cmd, preserves fresh *.cmd and symlinked .cmd', () => { + const repo = mkrepo('cf9') + const classifyDir = path.join(repo, '.checkpoints', 'classify') + fs.mkdirSync(classifyDir, { recursive: true }) + const oldCmd = path.join(classifyDir, 'pending-old.cmd') + const freshCmd = path.join(classifyDir, 'pending-fresh.cmd') + fs.writeFileSync(oldCmd, 'x') + fs.writeFileSync(freshCmd, 'y') + const ago = (Date.now() - 60 * 24 * 60 * 60 * 1000) / 1000 + fs.utimesSync(oldCmd, ago, ago) + const external = mktmp('cf9-target') + const linkTarget = path.join(external, 'z.cmd') + fs.writeFileSync(linkTarget, 'z') + const link = path.join(classifyDir, 'pending-linked.cmd') + fs.symlinkSync(linkTarget, link) + try { fs.lutimesSync(link, ago, ago) } catch {} + + const v = runHelper(['--vacuum', '--project-root', repo, '--max-age-days', '30'], + { cwd: repo, env: { CLAUDE_CODE_SESSION_ID: '' } }) + assert.strictEqual(v.status, 0, `vacuum: ${v.stderr}`) + assert.ok(!fs.existsSync(oldCmd), 'aged .cmd must be reaped') + assert.ok(fs.existsSync(freshCmd), 'fresh .cmd must be preserved') + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'symlinked .cmd must be preserved') + assert.ok(fs.existsSync(linkTarget), 'symlink target must be intact') +}) + // ----- PR-B2 S3: --target-path path-verdict mode (§11/§14-F4/§15-C2) ----- test('§P1 path verdict write + read round-trip (existing target)', () => { diff --git a/tests/test-em-recall-sessionstart.sh b/tests/test-em-recall-sessionstart.sh index 200bc3e..3885a89 100755 --- a/tests/test-em-recall-sessionstart.sh +++ b/tests/test-em-recall-sessionstart.sh @@ -173,15 +173,21 @@ fi echo "" echo "--- #103 hook freshness warnings ---" # ============================================================================ +# F1 regression (diagnosis 20260611-234742): the fixture mirrors the REAL +# post-PR-373 repo layout — sources under plugins/claude-code/hooks/, NO +# top-level hooks/ directory. The old line-62 probe `[ ! -d "$source_repo/hooks" ]` +# emitted a false "source repo unavailable" every SessionStart against this +# layout; test 10 fails if that probe regresses. FRESH_SRC="$TEST_DIR/fresh-source-repo" +FRESH_SRC_HOOKS="$FRESH_SRC/plugins/claude-code/hooks" FRESH_INSTALLED="$TEST_HOME/.claude/hooks" -mkdir -p "$FRESH_SRC/hooks/lib" "$FRESH_INSTALLED/lib" "$TEST_HOME/.episodic-memory" -cp "$REPO_ROOT/plugins/claude-code/hooks/plan-gate.sh" "$FRESH_SRC/hooks/plan-gate.sh" -cp "$REPO_ROOT/plugins/claude-code/hooks/em-recall-sessionstart.sh" "$FRESH_SRC/hooks/em-recall-sessionstart.sh" -cp "$REPO_ROOT/plugins/claude-code/hooks/lib/command-classifier.sh" "$FRESH_SRC/hooks/lib/command-classifier.sh" -cp "$FRESH_SRC/hooks/plan-gate.sh" "$FRESH_INSTALLED/plan-gate.sh" -cp "$FRESH_SRC/hooks/em-recall-sessionstart.sh" "$FRESH_INSTALLED/em-recall-sessionstart.sh" -cp "$FRESH_SRC/hooks/lib/command-classifier.sh" "$FRESH_INSTALLED/lib/command-classifier.sh" +mkdir -p "$FRESH_SRC_HOOKS/lib" "$FRESH_INSTALLED/lib" "$TEST_HOME/.episodic-memory" +cp "$REPO_ROOT/plugins/claude-code/hooks/plan-gate.sh" "$FRESH_SRC_HOOKS/plan-gate.sh" +cp "$REPO_ROOT/plugins/claude-code/hooks/em-recall-sessionstart.sh" "$FRESH_SRC_HOOKS/em-recall-sessionstart.sh" +cp "$REPO_ROOT/plugins/claude-code/hooks/lib/command-classifier.sh" "$FRESH_SRC_HOOKS/lib/command-classifier.sh" +cp "$FRESH_SRC_HOOKS/plan-gate.sh" "$FRESH_INSTALLED/plan-gate.sh" +cp "$FRESH_SRC_HOOKS/em-recall-sessionstart.sh" "$FRESH_INSTALLED/em-recall-sessionstart.sh" +cp "$FRESH_SRC_HOOKS/lib/command-classifier.sh" "$FRESH_INSTALLED/lib/command-classifier.sh" cat > "$TEST_HOME/.episodic-memory/hook-install.json" < "$TEST_HOME/.episodic-memory/hook-install.json" <> "$FRESH_INSTALLED/lib/command-classifier.sh" output="$(run_hook_capture)" if echo "$output" | grep -q "plugins/claude-code/hooks/lib/command-classifier.sh"; then @@ -246,13 +258,30 @@ else ((failed++)) fi +# Individual source file gone under an EXISTING repo → per-file loop +# classifies it missing_source (the loop, not the root probe, owns +# file-level classification post-F1). +cp "$FRESH_SRC_HOOKS/lib/command-classifier.sh" "$FRESH_INSTALLED/lib/command-classifier.sh" +rm "$FRESH_SRC_HOOKS/plan-gate.sh" +output="$(run_hook_capture)" +if echo "$output" | grep -q "missing source files" \ + && echo "$output" | grep -q "plugins/claude-code/hooks/plan-gate.sh"; then + echo " ✓ 13. Missing individual source file is classified by the per-file loop" + ((passed++)) +else + echo " ✗ 13. Missing individual source file was not reported" + echo " output: $output" + ((failed++)) +fi +cp "$REPO_ROOT/plugins/claude-code/hooks/plan-gate.sh" "$FRESH_SRC_HOOKS/plan-gate.sh" + mv "$FRESH_SRC" "$FRESH_SRC.moved" output="$(run_hook_capture)" if echo "$output" | grep -q "source repo unavailable"; then - echo " ✓ 13. Missing source repo is reported" + echo " ✓ 14. Missing source repo is reported" ((passed++)) else - echo " ✗ 13. Missing source repo was not reported" + echo " ✗ 14. Missing source repo was not reported" echo " output: $output" ((failed++)) fi diff --git a/tests/test-install-hooks.sh b/tests/test-install-hooks.sh index 73e7f9a..fc65e71 100644 --- a/tests/test-install-hooks.sh +++ b/tests/test-install-hooks.sh @@ -93,15 +93,17 @@ manifest_source=$(jq -r '.source_repo' "$TEST_HOME/.episodic-memory/hook-install assert_eq "T1b7 hook freshness manifest records source repo (#103)" "$REPO_ROOT" "$manifest_source" managed_count=$(jq '[.files[]? | select(.relative_path | test("^plugins/claude-code/hooks/.*\\.sh$"))] | length' "$TEST_HOME/.episodic-memory/hook-install.json") -# 6 hook .sh files (checkpoint-gate, plan-gate, preflight-gate, -# preflight-prompt-helper, em-recall-sessionstart, stop-gate) + 6 -# hooks/lib/.sh files (command-classifier, repo-root, marker-paths, -# session-id, agent-classifier, agent-classifier-deny-reason) = 12. +# 7 hook .sh files (checkpoint-gate, plan-gate, preflight-gate, +# preflight-prompt-helper, em-recall-sessionstart, session-handoff-prompt, +# stop-gate) + 6 hooks/lib/.sh files (command-classifier, repo-root, +# marker-paths, session-id, agent-classifier, agent-classifier-deny-reason) = 13. # NOTE (PR-B): was hardcoded "10" and went stale when PR #331 added the # classifier lib (this test runs in no CI workflow). Corrected to 11, then to 12 -# when PR-B2 (#351) added the 3-way deny-hint lib agent-classifier-deny-reason.sh. -# The test is wired into CI (plan-marker-validate.yml) to catch future drift. -assert_eq "T1b8 hook freshness manifest covers managed hooks and libs (#103)" "12" "$managed_count" +# when PR-B2 (#351) added the 3-way deny-hint lib agent-classifier-deny-reason.sh, +# then to 13 when checkpoint-hygiene F3 brought session-handoff-prompt.sh under +# HOOK_SPECS. The test is wired into CI (plan-marker-validate.yml) to catch +# future drift. +assert_eq "T1b8 hook freshness manifest covers managed hooks and libs (#103)" "13" "$managed_count" pg_manifest=$(jq -r '.files[] | select(.relative_path == "plugins/claude-code/hooks/plan-gate.sh") | .installed_path' "$TEST_HOME/.episodic-memory/hook-install.json") assert_eq "T1b9 hook freshness manifest records plan-gate install path (#103)" "$TEST_HOME/.claude/hooks/plan-gate.sh" "$pg_manifest" @@ -120,6 +122,24 @@ assert_eq "T1c3 plan-gate registered with no matcher (runs on every PreToolUse)" ss_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command|test("em-recall-sessionstart"))] | length' "$TEST_HOME/.claude/settings.json") assert_eq "T1d SessionStart contains exactly one em-recall-sessionstart entry" "1" "$ss_count" +# session-handoff-prompt.sh tracking (checkpoint-hygiene F3): copied, +# registered exactly once with timeout 5, manifest row, and ordered AFTER +# em-recall-sessionstart (matches the pre-existing manual registration). +[ -f "$TEST_HOME/.claude/hooks/session-handoff-prompt.sh" ] && r=true || r=false +assert_eq "T1d2 session-handoff-prompt.sh copied to ~/.claude/hooks/ (F3)" "true" "$r" + +shp_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command|test("session-handoff-prompt"))] | length' "$TEST_HOME/.claude/settings.json") +assert_eq "T1d3 SessionStart contains exactly one session-handoff-prompt entry (F3)" "1" "$shp_count" + +shp_timeout=$(jq -r '.hooks.SessionStart[]?.hooks[]? | select(.command|test("session-handoff-prompt")) | .timeout' "$TEST_HOME/.claude/settings.json") +assert_eq "T1d4 session-handoff-prompt entry timeout=5s (F3)" "5" "$shp_timeout" + +shp_manifest=$(jq -r '.files[] | select(.relative_path == "plugins/claude-code/hooks/session-handoff-prompt.sh") | .installed_path' "$TEST_HOME/.episodic-memory/hook-install.json") +assert_eq "T1d5 hook freshness manifest records session-handoff-prompt install path (F3)" "$TEST_HOME/.claude/hooks/session-handoff-prompt.sh" "$shp_manifest" + +shp_order=$(jq '[.hooks.SessionStart[].hooks[0].command] as $c | ($c | map(test("em-recall-sessionstart")) | index(true)) < ($c | map(test("session-handoff-prompt")) | index(true))' "$TEST_HOME/.claude/settings.json") +assert_eq "T1d6 em-recall-sessionstart registered before session-handoff-prompt (F3)" "true" "$shp_order" + se_count=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select(.command|test("em-session-end-prompt"))] | length' "$TEST_HOME/.claude/settings.json") assert_eq "T1e SessionEnd contains exactly one em-session-end-prompt entry (nested shape)" "1" "$se_count" @@ -146,6 +166,9 @@ assert_eq "T2b2 still exactly one plan-gate entry after re-run (#86 PR-A)" "1" " ss_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command|test("em-recall-sessionstart"))] | length' "$TEST_HOME/.claude/settings.json") assert_eq "T2c still exactly one em-recall-sessionstart entry after re-run" "1" "$ss_count" +shp_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command|test("session-handoff-prompt"))] | length' "$TEST_HOME/.claude/settings.json") +assert_eq "T2c2 still exactly one session-handoff-prompt entry after re-run (F3)" "1" "$shp_count" + se_count=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select(.command|test("em-session-end-prompt"))] | length' "$TEST_HOME/.claude/settings.json") assert_eq "T2d still exactly one em-session-end-prompt entry after re-run" "1" "$se_count" @@ -227,7 +250,7 @@ if echo "$output" | grep -q "stale PreToolUse entry for plan-gate.sh"; then r=tr assert_eq "T4b3 stale-canonical warning printed for non-canonical /some/user/plan-gate.sh" "true" "$r" ss_total=$(jq '.hooks.SessionStart | length' "$TEST_HOME/.claude/settings.json") -assert_eq "T4c SessionStart now has 2 entries (existing + em-recall-sessionstart)" "2" "$ss_total" +assert_eq "T4c SessionStart now has 3 entries (existing + em-recall-sessionstart + session-handoff-prompt)" "3" "$ss_total" rules_check_intact=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command|test("rules-check"))] | length' "$TEST_HOME/.claude/settings.json") assert_eq "T4d existing rules-check.sh entry preserved" "1" "$rules_check_intact" diff --git a/tests/test-preflight-marker-reapers.mjs b/tests/test-preflight-marker-reapers.mjs new file mode 100644 index 0000000..2b8c05c --- /dev/null +++ b/tests/test-preflight-marker-reapers.mjs @@ -0,0 +1,323 @@ +#!/usr/bin/env node +/** + * test-preflight-marker-reapers.mjs — checkpoint-hygiene F4 (closes #283 + * lifecycle halves): reapers for the per-session preflight families + * .preflight-done. + * .last-user-prompt..json + * + * Branch B1 — SessionEnd own-session reap (em-session-end-prompt.mjs): + * R1 own-sid both families removed; other-sid preserved + * R2 invalid sid → both preserved + * R3 nested cwd → reap at MAIN repo root; subdir canary untouched + * R4 linked worktree → reap at MAIN root; worktree-local canary untouched + * R5 non-git cwd → resolveRepoRoot falls back to cwd; reap binds there + * R6 fake HOME with its own .checkpoints → never touched + * R7 caller process-cwd != stdin .cwd → stdin .cwd wins; caller canary untouched + * + * Branch B2 — SessionStart 7-day orphan sweep (em-recall.mjs): + * S1 8-day-old suffixed pair reaped; 1-hour-old pair preserved + * S2 legacy suffix-less .preflight-done (old) preserved (F7 scope) + * S3 symlink matching the family RE preserved; target intact + * S4 non-matching basenames (old) preserved + * S5 nested cwd → sweep at MAIN root; subdir canary untouched + * S6 linked worktree cwd → sweep at MAIN root; worktree-local canary untouched + * S7 non-git cwd → sweep binds to cwd fallback + * S8 fake HOME with its own old markers → never touched + * + * Authority-root contract per plan review R3 ACCEPT + * (episode 20260612-094844-reply-codex-...-32bf). + */ + +import fs from 'fs' +import path from 'path' +import os from 'os' +import { spawnSync } from 'child_process' + +const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') +const SESSION_END = path.join(REPO, 'scripts', 'em-session-end-prompt.mjs') +const EM_RECALL = path.join(REPO, 'scripts', 'em-recall.mjs') + +const SID_A = 'session-a' +const SID_B = 'session-b' +const DAY = 24 * 60 * 60 + +let passed = 0 +let failed = 0 +const cleanups = [] +process.on('exit', () => { for (const fn of cleanups) try { fn() } catch {} }) + +function check(cond, msg) { + if (cond) { passed++; console.log(` PASS ${msg}`) } + else { failed++; console.log(` FAIL ${msg}`) } +} + +function mkTmpDir(prefix, { git = false } = {}) { + const real = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))) + fs.mkdirSync(path.join(real, '.checkpoints'), { recursive: true }) + if (git) spawnSync('git', ['init', '-q'], { cwd: real }) + cleanups.push(() => fs.rmSync(real, { recursive: true, force: true })) + return real +} + +function mkFakeHome() { + const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'reaper-home-'))) + fs.mkdirSync(path.join(home, '.checkpoints'), { recursive: true }) + cleanups.push(() => fs.rmSync(home, { recursive: true, force: true })) + return home +} + +function familyBasenames(sid) { + return [`.preflight-done.${sid}`, `.last-user-prompt.${sid}.json`] +} + +function seedFamilies(dir, sid, secondsAgo = 60) { + for (const b of familyBasenames(sid)) { + const p = path.join(dir, b) + fs.writeFileSync(p, '{}') + const ts = (Date.now() / 1000) - secondsAgo + fs.utimesSync(p, ts, ts) + } +} + +function familiesExist(dir, sid) { + return familyBasenames(sid).map(b => fs.existsSync(path.join(dir, b))) +} + +function runSessionEnd(stdinCwd, sid, { home, processCwd } = {}) { + return spawnSync('node', [SESSION_END], { + input: JSON.stringify({ session_id: sid, cwd: stdinCwd, hook_event_name: 'SessionEnd' }), + cwd: processCwd || stdinCwd, + encoding: 'utf8', + env: { ...process.env, HOME: home || mkFakeHome() }, + }) +} + +function runSessionStart(cwd, { home } = {}) { + return spawnSync('node', [EM_RECALL, '--limit', '5', '--session-start', '--session-id', SID_A], { + cwd, + encoding: 'utf8', + env: { ...process.env, HOME: home || mkFakeHome() }, + }) +} + +function mkWorktree(mainRepo) { + // A linked worktree needs at least one commit in main. + fs.writeFileSync(path.join(mainRepo, 'seed.txt'), 'seed') + spawnSync('git', ['add', 'seed.txt'], { cwd: mainRepo }) + spawnSync('git', [ + '-c', 'user.email=juan.delacruz@acme.com', '-c', 'user.name=juan', + 'commit', '-q', '-m', 'seed', + ], { cwd: mainRepo }) + const wt = path.join(mainRepo, '..', `${path.basename(mainRepo)}-wt`) + spawnSync('git', ['worktree', 'add', '-q', wt], { cwd: mainRepo }) + fs.mkdirSync(path.join(wt, '.checkpoints'), { recursive: true }) + cleanups.push(() => fs.rmSync(wt, { recursive: true, force: true })) + return fs.realpathSync(wt) +} + +// ============================ B1: SessionEnd reap ============================ + +{ + console.log('R1: own-sid both families removed; other-sid preserved') + const repo = mkTmpDir('reaper-r1-', { git: true }) + const ck = path.join(repo, '.checkpoints') + seedFamilies(ck, SID_A) + seedFamilies(ck, SID_B) + const r = runSessionEnd(repo, SID_A) + check(r.status === 0, `R1 exit 0 (got ${r.status})`) + check(familiesExist(ck, SID_A).every(x => !x), 'R1 own-sid pair removed') + check(familiesExist(ck, SID_B).every(x => x), 'R1 other-sid pair preserved') +} + +{ + console.log('R2: invalid sid → both preserved') + const repo = mkTmpDir('reaper-r2-', { git: true }) + const ck = path.join(repo, '.checkpoints') + seedFamilies(ck, SID_A) + const r = runSessionEnd(repo, '../evil/invalid') + check(r.status === 0, `R2 exit 0 (got ${r.status})`) + check(familiesExist(ck, SID_A).every(x => x), 'R2 families preserved on invalid sid') +} + +{ + console.log('R3: nested cwd → reap at MAIN repo root; subdir canary untouched') + const repo = mkTmpDir('reaper-r3-', { git: true }) + const sub = path.join(repo, 'sub') + fs.mkdirSync(path.join(sub, '.checkpoints'), { recursive: true }) + seedFamilies(path.join(repo, '.checkpoints'), SID_A) + seedFamilies(path.join(sub, '.checkpoints'), SID_A) + const r = runSessionEnd(sub, SID_A) + check(r.status === 0, `R3 exit 0 (got ${r.status})`) + check(familiesExist(path.join(repo, '.checkpoints'), SID_A).every(x => !x), + 'R3 main-root pair removed (resolveRepoRoot(subdir) → repo)') + check(familiesExist(path.join(sub, '.checkpoints'), SID_A).every(x => x), + 'R3 subdir canary untouched') +} + +{ + console.log('R4: linked worktree → reap at MAIN root; worktree-local canary untouched') + const repo = mkTmpDir('reaper-r4-', { git: true }) + const wt = mkWorktree(repo) + seedFamilies(path.join(repo, '.checkpoints'), SID_A) + seedFamilies(path.join(wt, '.checkpoints'), SID_A) + const r = runSessionEnd(wt, SID_A) + check(r.status === 0, `R4 exit 0 (got ${r.status})`) + check(familiesExist(path.join(repo, '.checkpoints'), SID_A).every(x => !x), + 'R4 MAIN-root pair removed (worktree converges to main)') + check(familiesExist(path.join(wt, '.checkpoints'), SID_A).every(x => x), + 'R4 worktree-local canary untouched') +} + +{ + console.log('R5: non-git cwd → reap binds to cwd fallback') + const dir = mkTmpDir('reaper-r5-') + const ck = path.join(dir, '.checkpoints') + seedFamilies(ck, SID_A) + const r = runSessionEnd(dir, SID_A) + check(r.status === 0, `R5 exit 0 (got ${r.status})`) + check(familiesExist(ck, SID_A).every(x => !x), 'R5 cwd-fallback pair removed') +} + +{ + console.log('R6: fake HOME .checkpoints never touched') + const repo = mkTmpDir('reaper-r6-', { git: true }) + const home = mkFakeHome() + seedFamilies(path.join(home, '.checkpoints'), SID_A) + seedFamilies(path.join(repo, '.checkpoints'), SID_A) + const r = runSessionEnd(repo, SID_A, { home }) + check(r.status === 0, `R6 exit 0 (got ${r.status})`) + check(familiesExist(path.join(home, '.checkpoints'), SID_A).every(x => x), + 'R6 HOME canaries untouched') + check(familiesExist(path.join(repo, '.checkpoints'), SID_A).every(x => !x), + 'R6 repo pair removed') +} + +{ + console.log('R7: caller process-cwd != stdin .cwd → stdin .cwd wins') + const repo = mkTmpDir('reaper-r7a-', { git: true }) + const caller = mkTmpDir('reaper-r7b-', { git: true }) + seedFamilies(path.join(repo, '.checkpoints'), SID_A) + seedFamilies(path.join(caller, '.checkpoints'), SID_A) + const r = runSessionEnd(repo, SID_A, { processCwd: caller }) + check(r.status === 0, `R7 exit 0 (got ${r.status})`) + check(familiesExist(path.join(repo, '.checkpoints'), SID_A).every(x => !x), + 'R7 stdin-.cwd repo pair removed') + check(familiesExist(path.join(caller, '.checkpoints'), SID_A).every(x => x), + 'R7 caller-cwd canary untouched') +} + +// ============================ B2: SessionStart sweep ============================ + +{ + console.log('S1: 8-day-old suffixed pair reaped; fresh pair preserved') + const repo = mkTmpDir('sweep-s1-', { git: true }) + const ck = path.join(repo, '.checkpoints') + seedFamilies(ck, 'old-crashed-sid', 8 * DAY) + seedFamilies(ck, SID_B, 3600) + const r = runSessionStart(repo) + check(r.status === 0, `S1 exit 0 (got ${r.status})`) + check(familiesExist(ck, 'old-crashed-sid').every(x => !x), 'S1 8-day-old pair reaped') + check(familiesExist(ck, SID_B).every(x => x), 'S1 fresh pair preserved') +} + +{ + console.log('S2: legacy suffix-less .preflight-done (old) preserved — F7 scope') + const repo = mkTmpDir('sweep-s2-', { git: true }) + const ck = path.join(repo, '.checkpoints') + const legacy = path.join(ck, '.preflight-done') + fs.writeFileSync(legacy, '') + const ts = (Date.now() / 1000) - 30 * DAY + fs.utimesSync(legacy, ts, ts) + const r = runSessionStart(repo) + check(r.status === 0, `S2 exit 0 (got ${r.status})`) + check(fs.existsSync(legacy), 'S2 legacy suffix-less preserved') +} + +{ + console.log('S3: symlink matching family RE preserved; target intact') + const repo = mkTmpDir('sweep-s3-', { git: true }) + const ck = path.join(repo, '.checkpoints') + const target = path.join(repo, 'external-target.json') + fs.writeFileSync(target, '{}') + const link = path.join(ck, '.preflight-done.linked-sid') + fs.symlinkSync(target, link) + const oldTs = (Date.now() / 1000) - 8 * DAY + try { fs.lutimesSync(link, oldTs, oldTs) } catch {} + fs.utimesSync(target, oldTs, oldTs) + const r = runSessionStart(repo) + check(r.status === 0, `S3 exit 0 (got ${r.status})`) + check(fs.lstatSync(link).isSymbolicLink(), 'S3 symlink preserved (lstat skip)') + check(fs.existsSync(target), 'S3 link target intact') +} + +{ + console.log('S4: non-matching basenames (old) preserved') + const repo = mkTmpDir('sweep-s4-', { git: true }) + const ck = path.join(repo, '.checkpoints') + const strays = ['.preflight-done-extra', '.last-user-prompt.json', '.preflight-done.', '.my-notes.json'] + for (const b of strays) { + const p = path.join(ck, b) + fs.writeFileSync(p, '') + const ts = (Date.now() / 1000) - 8 * DAY + fs.utimesSync(p, ts, ts) + } + const r = runSessionStart(repo) + check(r.status === 0, `S4 exit 0 (got ${r.status})`) + check(strays.every(b => fs.existsSync(path.join(ck, b))), 'S4 all non-matching strays preserved') +} + +{ + console.log('S5: nested cwd → sweep at MAIN root; subdir canary untouched') + const repo = mkTmpDir('sweep-s5-', { git: true }) + const sub = path.join(repo, 'sub') + fs.mkdirSync(path.join(sub, '.checkpoints'), { recursive: true }) + seedFamilies(path.join(repo, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + seedFamilies(path.join(sub, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + const r = runSessionStart(sub) + check(r.status === 0, `S5 exit 0 (got ${r.status})`) + check(familiesExist(path.join(repo, '.checkpoints'), 'old-crashed-sid').every(x => !x), + 'S5 main-root pair reaped (resolveRepoRoot(subdir) → repo)') + check(familiesExist(path.join(sub, '.checkpoints'), 'old-crashed-sid').every(x => x), + 'S5 subdir canary untouched') +} + +{ + console.log('S6: linked worktree cwd → sweep at MAIN root; worktree canary untouched') + const repo = mkTmpDir('sweep-s6-', { git: true }) + const wt = mkWorktree(repo) + seedFamilies(path.join(repo, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + seedFamilies(path.join(wt, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + const r = runSessionStart(wt) + check(r.status === 0, `S6 exit 0 (got ${r.status})`) + check(familiesExist(path.join(repo, '.checkpoints'), 'old-crashed-sid').every(x => !x), + 'S6 MAIN-root pair reaped (worktree converges to main)') + check(familiesExist(path.join(wt, '.checkpoints'), 'old-crashed-sid').every(x => x), + 'S6 worktree-local canary untouched') +} + +{ + console.log('S7: non-git cwd → sweep binds to cwd fallback') + const dir = mkTmpDir('sweep-s7-') + const ck = path.join(dir, '.checkpoints') + seedFamilies(ck, 'old-crashed-sid', 8 * DAY) + const r = runSessionStart(dir) + check(r.status === 0, `S7 exit 0 (got ${r.status})`) + check(familiesExist(ck, 'old-crashed-sid').every(x => !x), 'S7 cwd-fallback pair reaped') +} + +{ + console.log('S8: fake HOME with its own old markers → never touched') + const repo = mkTmpDir('sweep-s8-', { git: true }) + const home = mkFakeHome() + seedFamilies(path.join(home, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + seedFamilies(path.join(repo, '.checkpoints'), 'old-crashed-sid', 8 * DAY) + const r = runSessionStart(repo, { home }) + check(r.status === 0, `S8 exit 0 (got ${r.status})`) + check(familiesExist(path.join(home, '.checkpoints'), 'old-crashed-sid').every(x => x), + 'S8 HOME canaries untouched') + check(familiesExist(path.join(repo, '.checkpoints'), 'old-crashed-sid').every(x => !x), + 'S8 repo pair reaped') +} + +console.log(`\nPassed: ${passed}\nFailed: ${failed}`) +process.exit(failed === 0 ? 0 : 1)