#!/usr/bin/env node // lib/util/test-lock.mjs // K8 -- the test lock. The executor (wave-1 order 1) locks a step's test files // once the test is written and seen failing, and unlocks them when the test // passes. Two guards read the lock: // - pre-write-executor.mjs refuses Write/Edit/NotebookEdit on a locked file; // - stop-test-lock.mjs refuses "done" while the session that took the lock // still holds it. // The lock lives in the repository's git directory (/voyage/ // test-lock.json), so it can never be committed, and each worktree has its own. // // Usage: // node lib/util/test-lock.mjs lock --files --step [--cmd ""] [--cwd ] // node lib/util/test-lock.mjs unlock [--cwd ] // node lib/util/test-lock.mjs status [--cwd ] (JSON on stdout) // node lib/util/test-lock.mjs green [--cmd ""] [--cwd ] // node lib/util/test-lock.mjs pending [--cwd ] // Exit: 0 = done, 1 = refused (another step holds the lock; for green: no // lock, or the test command did not exit 0), 2 = could not run (not a // repository, bad usage, unreadable lock, a command outside the test-runner // allowlist). 2 is never "unlocked" and never green. // // The green mark (operator decision 2026-10-08). The stop guard blocked Stop // three times while the executor waited for its controller, with the step's // test already green (voyage-lekerepo trial, 2026-10-06). `green` RUNS the // step's test command (the same allowlist and denylist as done-line.mjs) and // records `green_at` only on exit 0. The command is the one stored at `lock // --cmd` time, never one chosen at mark time: an independent review showed // `green --cmd "git status"` marking a red step green. A lock without a // stored command is never marked (exit 2); a --cmd that differs is refused; the stop guard then lets the session // stop, while the write guard keeps refusing the locked files. `pending` // clears the mark when a fix round starts; a new `lock` starts without one. // Paths in --files are relative to the --cwd (default: the current directory). // The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in // Bash tool subprocesses (code.claude.com/docs/en/env-vars). // // Credit: locking tests during a fix is Claude Code's own hook pattern // (hooks-guide "Block edits to protected files"). import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs'; import { DEFAULT_TIMEOUT_MS, allowedCommand, commandWords, runCommand, screenCommand, stoppedBy } from '../verification/criteria-runner.mjs'; import { isMain } from './is-main.mjs'; export const LOCK_VERSION = 1; export function lockPathFor(gitDir) { return join(gitDir, 'voyage', 'test-lock.json'); } /** * Read the lock for the repo that `start` (a file or directory) belongs to. * @returns {{ state: 'no-repo'|'none'|'locked'|'corrupt', lock?: object, path?: string, error?: string }} */ export function readLock(start) { const repo = resolveRepo(start); if (!repo) return { state: 'no-repo' }; const path = lockPathFor(repo.gitDir); if (!existsSync(path)) return { state: 'none', path, repo }; try { const lock = JSON.parse(readFileSync(path, 'utf8')); if (!lock || !Array.isArray(lock.files) || !Number.isInteger(lock.step)) { return { state: 'corrupt', path, repo, error: 'lock has no files/step' }; } return { state: 'locked', lock, path, repo }; } catch (e) { return { state: 'corrupt', path, repo, error: e.message }; } } // A pointer from the session that took a lock to the lock file, so the stop // guard finds it even after the session has moved to another directory. const SESSION_DIR = join(tmpdir(), `voyage-test-locks-${process.getuid ? process.getuid() : 'u'}`); const sessionPointer = (sessionId) => join(SESSION_DIR, `${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32)}.json`); // Pointers whose lock is gone are dropped on the next lock. function sweepPointers() { try { for (const f of readdirSync(SESSION_DIR)) { const p = join(SESSION_DIR, f); try { if (!existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path)) rmSync(p, { force: true }); } catch { rmSync(p, { force: true }); } } } catch { /* no dir yet */ } } function writeSessionPointer(sessionId, lockPath) { if (!sessionId) return; try { mkdirSync(SESSION_DIR, { recursive: true }); sweepPointers(); writeFileSync(sessionPointer(sessionId), JSON.stringify({ lock_path: lockPath })); } catch { /* the cwd lookup still works */ } } function removeSessionPointer(sessionId) { if (sessionId) rmSync(sessionPointer(sessionId), { force: true }); } /** * The lock this session holds, wherever it lives: { state: 'locked', lock, path } * or { state: 'none' }. A pointer to a lock that is gone, or that another * session now holds, is not this session's lock. */ export function lockForSession(sessionId) { if (!sessionId) return { state: 'none' }; try { const { lock_path } = JSON.parse(readFileSync(sessionPointer(sessionId), 'utf8')); const lock = JSON.parse(readFileSync(lock_path, 'utf8')); if (lock?.session === sessionId && Array.isArray(lock.files) && Number.isInteger(lock.step)) { return { state: 'locked', lock, path: lock_path }; } } catch { /* no pointer, or the lock is gone */ } return { state: 'none' }; } // Is `file` one of the locked files? Compared as canonical real paths. export function isLockedFile(lock, file) { const c = canonicalPath(file); return lock.files.includes(c); } function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; if (a === '--files' || a === '--step' || a === '--cwd' || a === '--cmd') { out[a.slice(2)] = argv[i + 1]; i += 1; } else out._.push(a); } return out; } function fail(code, msg) { process.stderr.write(`[voyage] test-lock: ${msg}\n`); process.exit(code); } function main(argv) { const args = parseArgs(argv); const cmd = args._[0]; if (!['lock', 'unlock', 'status', 'green', 'pending'].includes(cmd)) { fail(2, 'usage: test-lock.mjs lock --files --step | unlock | status | green --cmd "" | pending [--cwd ]'); } const cwd = resolve(args.cwd || process.cwd()); const cur = readLock(cwd); if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`); if (cmd === 'status') { if (cur.state === 'corrupt') { process.stdout.write(JSON.stringify({ locked: null, state: 'corrupt', lock_path: cur.path, error: cur.error }, null, 2) + '\n'); process.exit(2); } const l = cur.lock || {}; process.stdout.write(JSON.stringify({ locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null, locked_at: l.locked_at ?? null, cmd: l.cmd ?? null, green_at: l.green_at ?? null, lock_path: cur.path, }, null, 2) + '\n'); process.exit(0); } if (cmd === 'green' || cmd === 'pending') { if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`); if (cur.state !== 'locked') fail(1, 'no lock to mark'); const lock = { ...cur.lock }; if (cmd === 'pending') { delete lock.green_at; } else { const testCmd = typeof lock.cmd === 'string' && lock.cmd.trim() !== '' ? lock.cmd : null; if (!testCmd) fail(2, 'the lock names no test command (lock with --cmd ""); never marked green'); // One reading of a command passed as written: its words, as runCommand // runs them, so a trailing comment (round 8) and the blanks between the // words (round 9) are no part of it. const words = (c) => commandWords(c).join(' '); if (args.cmd !== undefined && words(args.cmd) !== words(testCmd)) fail(2, `--cmd is not the step's test command stored in the lock (${testCmd})`); const allowed = allowedCommand(testCmd, { cwd: cur.repo.root }); if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`); const screen = screenCommand(testCmd); if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`); // No shell: the words the screen checked are the words that run. const r = runCommand(testCmd, { cwd: cur.repo.root, timeoutMs: DEFAULT_TIMEOUT_MS }); // With no shell, 126/127 is the test program's own exit: a red, not "could not run". // A stop is read once, by stoppedBy (round 8). const stopped = stoppedBy(r, { timeoutMs: DEFAULT_TIMEOUT_MS }); if (stopped) fail(2, `the test command ${stopped.why}`); if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`); lock.green_at = new Date().toISOString(); } writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n'); process.exit(0); } if (cmd === 'unlock') { if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`); removeSessionPointer(cur.lock?.session); rmSync(cur.path, { force: true }); process.exit(0); } // lock if (!args.files) fail(2, 'lock needs --files '); if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step (a whole number)'); const step = Number(args.step); if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`); if (cur.state === 'locked' && cur.lock.step !== step) { fail(1, `step ${cur.lock.step} holds the lock; unlock it first`); } const files = String(args.files).split(',').map((f) => f.trim()).filter(Boolean).map((f) => canonicalPath(resolve(cwd, f))); if (files.length === 0) fail(2, 'lock needs at least one file'); const lock = { version: LOCK_VERSION, step, files: [...new Set(files)], session: process.env.CLAUDE_CODE_SESSION_ID || null, locked_at: new Date().toISOString(), }; if (args.cmd !== undefined) { if (String(args.cmd).trim() === '') fail(2, '--cmd is empty'); lock.cmd = String(args.cmd); } mkdirSync(dirname(cur.path), { recursive: true }); writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n'); writeSessionPointer(lock.session, cur.path); process.exit(0); } if (isMain(import.meta.url)) main(process.argv.slice(2));