// 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]); }