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>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:09:32 +02:00
commit 2ee8137e7c
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
31 changed files with 3278 additions and 107 deletions

View file

@ -0,0 +1,39 @@
// hooks/lib/contract-cli.mjs
// Runs a contract CLI that another wave-1 order owns (K2 approval log, K3 plan
// gate, K5 arch rules) and maps its answer. A CLI that is not there yet, or
// that cannot run, is "not measured" / "not logged" -- never green.
import { existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const CONTRACTS = {
K2: join(PLUGIN_ROOT, 'lib', 'util', 'approval-log.mjs'),
K3: join(PLUGIN_ROOT, 'lib', 'validators', 'plan-gate.mjs'),
K5: join(PLUGIN_ROOT, 'lib', 'verification', 'arch-rules.mjs'),
};
/**
* @returns {{ ran: boolean, code: number|null, stdout: string, stderr: string, why?: string }}
* ran=false when the CLI file is missing or could not be started.
*/
export function runContract(id, args, { cwd, timeout = 60_000 } = {}) {
const cli = CONTRACTS[id];
if (!existsSync(cli)) return { ran: false, code: null, stdout: '', stderr: '', why: `${id} CLI not present (${cli})` };
// No shell: every argument is passed as-is, so no text is ever spliced into a command line.
const r = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', timeout });
if (r.error || r.status === null) {
return { ran: false, code: null, stdout: r.stdout || '', stderr: r.stderr || '', why: `${id} could not run (${r.error?.message || 'signal'})` };
}
return { ran: true, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
}
// A Voyage plan carries `plan_version` in its leading frontmatter block.
export function isVoyagePlan(text) {
if (typeof text !== 'string') return false;
const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
return !!m && /^plan_version\s*:/m.test(m[1]);
}