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>
39 lines
1.9 KiB
JavaScript
39 lines
1.9 KiB
JavaScript
// 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]);
|
|
}
|