voyage/hooks/scripts/pre-plan-gate.mjs
Kjell Tore Guttormsen 2ee8137e7c
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>
2026-10-06 20:09:32 +02:00

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}`);