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