#!/usr/bin/env node // Hook: stop-test-lock.mjs // Event: Stop // Purpose: P9 (3) -- "done" is refused while a step's tests are locked (K8, // lib/util/test-lock.mjs). The executor unlocks a step only after its test // passes, so a held lock means the step's test has not been seen passing. // // Scope: only the session that took the lock is held (the lock records // CLAUDE_CODE_SESSION_ID, which the docs say matches the hook input's // session_id: code.claude.com/docs/en/env-vars). Any other session // in the same repository stops freely, so a lock left behind by a crash // cannot trap unrelated work. // Loop guard: when stop_hook_active is true, Claude Code is already continuing // because of a stop hook; this hook then lets the stop through // (code.claude.com/docs/en/hooks section Stop input; hooks-guide "Stop hook hits // the block cap"). // TaskCompleted is not used: it fires on the TaskUpdate tool, which current // models do not get by default (tools-reference section Task tool availability). // // Credit: refusing "done" until the check passes is Claude Code's own // documented pattern (hooks reference section TaskCompleted / Stop decision control). import { readFileSync } from 'node:fs'; import { readLock, lockForSession } from '../../lib/util/test-lock.mjs'; let input; try { input = JSON.parse(readFileSync(0, 'utf-8')); } catch { process.exit(0); } if (input?.stop_hook_active === true) process.exit(0); // The lock in the session's current repo, else the one this session took // elsewhere (the session may have moved to another directory since). let lock = readLock(input?.cwd || process.cwd()); if (lock.state !== 'locked' || lock.lock.session !== input?.session_id) lock = lockForSession(input?.session_id); if (lock.state !== 'locked') process.exit(0); if (!lock.lock.session || lock.lock.session !== input?.session_id) process.exit(0); // The step's test has been seen passing by a command (`test-lock.mjs green`): // the session may stop -- e.g. to wait for its controller -- while the write // guard keeps the test files locked (operator decision 2026-10-08). if (lock.lock.green_at) process.exit(0); const files = lock.lock.files.join(', '); process.stdout.write(JSON.stringify({ decision: 'block', reason: `[voyage] Not done: step ${lock.lock.step} still holds its test lock (${files}). ` + "The step is done when its test passes and the executor unlocks it. Run the step's " + 'test; if it passes, finish the step. If it cannot pass, say so plainly and stop.', })); process.exit(0);