// tests/hooks/plan-gate-hook.test.mjs // P3 — Claude Code's plan approval, wrapped. A PreToolUse hook on ExitPlanMode // receives the plan text and the plan file path (code.claude.com/docs/en/hooks // § ExitPlanMode: «Claude Code injects the plan content and file path before // passing the input to hooks»), runs Voyage's plan gate (contract K3, // lib/validators/plan-gate.mjs, owned by wave-1 order 3; a STUB here until it // is merged) and sends an invalid Voyage plan back. Only Voyage plans (they // carry `plan_version`) are gated: the hook runs in every session, so every // other plan passes untouched. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { runHookWithEnv } from '../helpers/hook-helper.mjs'; import { scriptsFor, pluginCopy, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs'; const K3 = 'lib/validators/plan-gate.mjs'; const STUBBED = pluginCopy({ [K3]: 'stub' }); const MISSING = pluginCopy({ [K3]: 'missing' }); const hookIn = (root) => join(root, 'hooks', 'scripts', 'pre-plan-gate.mjs'); const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n'; const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n'; function planFile(text) { const dir = join(tmp(), 'plans'); mkdirSync(dir, { recursive: true }); const p = join(dir, 'plan.md'); writeFileSync(p, text); return p; } async function exitPlanMode(root, plan, env = {}, { withPath = true } = {}) { const logFile = join(tmp(), 'stub.jsonl'); const input = { plan }; if (withPath) input.planFilePath = planFile(plan); const r = await runHookWithEnv( hookIn(root), { hook_event_name: 'PreToolUse', tool_name: 'ExitPlanMode', cwd: tmp(), tool_input: input }, { STUB_LOG: logFile, ...env }, ); let out = null; try { out = parseJsonOut(r.stdout); } catch { out = null; } return { ...r, out, calls: readStubLog(logFile), path: input.planFilePath }; } test('P3 must stop: a Voyage plan the gate refuses (K3 exit 1, open BLOCKER) is sent back', async () => { const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '1', STUB_STDOUT: 'BLOCKER open: step 2 has no Verify' }); assert.equal(r.code, 2, `deny = exit 2; stdout ${r.stdout}`); assert.match(r.stderr, /Voyage/); assert.match(r.stderr, /BLOCKER open/, 'the gate\'s own words reach Claude'); assert.equal(r.calls.length, 1); assert.equal(r.calls[0].argv[0], r.path, 'the gate runs on the plan file Claude Code attached'); }); test('P3 must pass: a Voyage plan the gate accepts (K3 exit 0) goes on', async () => { const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '0' }); assert.equal(r.code, 0, r.stderr); assert.equal(r.out, null, 'no decision: Claude Code\'s own approval prompt follows'); assert.equal(r.calls.length, 1); }); test('P3 must pass: a plan without plan_version is not Voyage\'s; the gate is not even run', async () => { const r = await exitPlanMode(STUBBED, OTHER_PLAN, { STUB_EXIT: '1' }); assert.equal(r.code, 0); assert.equal(r.out, null); assert.equal(r.calls.length, 0); }); test('P3 not measured: K3 could not run (exit 2) -> ask, never a silent pass', async () => { const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, { STUB_EXIT: '2' }); assert.equal(r.code, 0); assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask'); assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i); }); test('P3 not measured: K3 missing (order 3 not merged) -> ask "not measured"', async () => { const r = await exitPlanMode(MISSING, VOYAGE_PLAN); assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask'); assert.match(r.out.hookSpecificOutput.permissionDecisionReason, /not measured/i); }); test('P3 not measured: a Voyage plan with no plan file path -> ask "not measured"', async () => { const r = await exitPlanMode(STUBBED, VOYAGE_PLAN, {}, { withPath: false }); assert.equal(r.out?.hookSpecificOutput?.permissionDecision, 'ask'); assert.equal(r.calls.length, 0); }); test('wiring: hooks.json runs pre-plan-gate on PreToolUse for ExitPlanMode only', () => { assert.ok(scriptsFor('PreToolUse', 'ExitPlanMode').some((p) => p.endsWith('pre-plan-gate.mjs'))); for (const tool of ['Bash', 'Write', 'Edit', 'Agent', 'EnterPlanMode']) { assert.ok(!scriptsFor('PreToolUse', tool).some((p) => p.endsWith('pre-plan-gate.mjs')), tool); } });