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>
71 lines
2.9 KiB
JavaScript
71 lines
2.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Hook: pre-plan-gate.mjs
|
|
// Event: PreToolUse (ExitPlanMode)
|
|
// Purpose: P3 -- wrap Claude Code's plan approval. Claude Code hands this hook
|
|
// the plan text and the plan file path (hooks reference section ExitPlanMode:
|
|
// "Claude Code injects the plan content and file path before passing the
|
|
// input to hooks"). For a Voyage plan (it carries `plan_version`) the hook
|
|
// runs Voyage's plan gate (contract K3, lib/validators/plan-gate.mjs) on that
|
|
// file and sends a plan the gate refuses back to planning. Every other plan
|
|
// passes untouched: the hook runs in every session.
|
|
//
|
|
// Precondition: Voyage's guards are deterministic only when no installed mod
|
|
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
|
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
|
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
|
//
|
|
// Answers:
|
|
// - K3 exit 0 -> exit 0, no output (Claude Code's own approval follows)
|
|
// - K3 exit 1 -> exit 2 (deny): the gate's words go to Claude
|
|
// - K3 exit 2 / missing / no plan file -> "ask" with "not measured": never a
|
|
// silent pass, and not a refusal of a plan nobody could check
|
|
// Credit: wrapping plan mode is Claude Code's own pattern (P3 analysis).
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { runContract, isVoyagePlan } from '../lib/contract-cli.mjs';
|
|
|
|
function ask(reason) {
|
|
process.stdout.write(JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
permissionDecision: 'ask',
|
|
permissionDecisionReason: `Voyage plan gate: not measured (${reason}). Approve only if you have checked the plan yourself.`,
|
|
},
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
let input;
|
|
try {
|
|
input = JSON.parse(readFileSync(0, 'utf-8'));
|
|
} catch {
|
|
// Unreadable input: nothing to judge, and no plan to call Voyage's.
|
|
process.exit(0);
|
|
}
|
|
|
|
const plan = input?.tool_input?.plan;
|
|
if (!isVoyagePlan(plan)) process.exit(0);
|
|
|
|
const cwd = input?.cwd || process.cwd();
|
|
const given = input?.tool_input?.planFilePath;
|
|
// Plan paths are absolute in practice; a relative one is read from the
|
|
// session's directory, the same one the gate runs in.
|
|
const planPath = typeof given === 'string' && given ? resolve(cwd, given) : null;
|
|
if (!planPath || !existsSync(planPath)) {
|
|
ask('Claude Code attached no readable plan file');
|
|
}
|
|
|
|
const r = runContract('K3', [planPath], { cwd });
|
|
if (!r.ran) ask(r.why);
|
|
if (r.code === 0) process.exit(0);
|
|
if (r.code === 1) {
|
|
const words = (r.stdout + r.stderr).trim().slice(0, 4000) || 'the plan gate refused the plan';
|
|
process.stderr.write(
|
|
'[voyage] Plan sent back: Voyage\'s plan gate refused this plan.\n' +
|
|
`${words}\n` +
|
|
'Way forward: fix what the gate names, or plan again; then ask for approval again.\n'
|
|
);
|
|
process.exit(2);
|
|
}
|
|
ask(`the plan gate exited ${r.code}`);
|