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
50
hooks/scripts/stop-test-lock.mjs
Normal file
50
hooks/scripts/stop-test-lock.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/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);
|
||||
|
||||
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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue