feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
186
lib/util/test-lock.mjs
Normal file
186
lib/util/test-lock.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/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 (<git dir>/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 <a,b> --step <N> [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
|
||||
// Exit: 0 = done, 1 = refused (another step holds the lock), 2 = could not
|
||||
// run (not a repository, bad usage, unreadable lock). 2 is never "unlocked".
|
||||
// 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, realpathSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolveRepo, canonicalPath } from '../verification/repo-tools.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') { 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'].includes(cmd)) fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status [--cwd <dir>]');
|
||||
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, lock_path: cur.path,
|
||||
}, 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 <a,b>');
|
||||
if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step <N> (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(),
|
||||
};
|
||||
mkdirSync(dirname(cur.path), { recursive: true });
|
||||
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
||||
writeSessionPointer(lock.session, cur.path);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) main(process.argv.slice(2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue