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>
95 lines
4.4 KiB
JavaScript
95 lines
4.4 KiB
JavaScript
// 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);
|
|
}
|
|
});
|