#!/usr/bin/env node /** * enforce-criterion-gate (Level B, М5-family) — PreToolUse Bash. На git commit/push требует, чтобы * КАЖДЫЙ критерий запечатанного плана, затронутый staged-кодом, имел настоящий по-критерийный GREEN * (test-passed И mutation-killed), свежий по отпечатку и подписанный (runCriterionGate). Строго сильнее * G1 (whole-suite): доказывает не «сюита зелёная», а «тесты изменённого кода реально что-то проверяют». * fail-CLOSE; escapable M6; docs-only short-circuit; рубильник общий с G1 (флаг+ключ; inert $0). Нет * плана на кодовое изменение → block (SE-LB-5). Регистрация в settings.json — шаг ВЛАДЕЛЬЦА. */ import { readStdin, parseEventJson, exitDecision, detectGitCommandKind, isDocsOnlyChange, listChangedFiles } from './enforce-hook-helpers.mjs'; import { runCriterionGate } from './judge-orchestrator.mjs'; import { verifyGateActive } from './verify-gate-config.mjs'; import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; /** Чистое решение. */ export function decide({ toolName, command, gate, key, codeChanged = false, frozenPlanValid = false, criteria = [], sealedCriterionIds = [], greenRuns = [], currentFingerprints = {}, escapeOpen = false, changedPaths = [] }) { if (toolName !== 'Bash' || typeof command !== 'string') return { block: false }; const kind = detectGitCommandKind(command); if (kind !== 'commit' && kind !== 'push') return { block: false }; if (isDocsOnlyChange(changedPaths)) return { block: false }; if (!gate || !gate.active) { if (gate && gate.keyMissing) return { block: true, message: '[criterion-gate] флаг ON, но ключ подписанта недоступен — fail-CLOSE' }; return { block: false }; // inert ($0) } if (escapeOpen) return { block: false }; if (codeChanged && !frozenPlanValid) { return { block: true, message: '[criterion-gate] кодовое изменение без валидного запечатанного плана — заморозьте план (нет по-критерийного доказательства)' }; } const r = runCriterionGate({ criteria, greenRuns, sealedCriterionIds, currentFingerprints, signerKey: key }); if (!r.passed) { return { block: true, message: `[criterion-gate] по-критерийный GREEN не доказан (${r.stoppedAt}${r.detail ? ': ' + JSON.stringify(r.detail) : ''}) — прогоните node tools/produce-criterion-greens.mjs` }; } return { block: false }; } // ── I/O-main (под активацией владельцем) ── async function main() { let event, gate; try { event = parseEventJson(await readStdin()); gate = verifyGateActive(); } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe ($0) if (!gate.active && !gate.keyMissing) { exitDecision({ block: false }); return; } try { const { loadFrozenPlan, verifyFrozenPlan, treeLeaves, sealedCriterionIds: sealedIds } = await import('./plan-lock.mjs'); const { codeFingerprint } = await import('./criterion-green.mjs'); const { mapStepToFiles } = await import('./produce-criterion-greens.mjs'); const { readFileSync, existsSync } = await import('node:fs'); const { join } = await import('node:path'); const { homedir } = await import('node:os'); const command = (event.tool_input && event.tool_input.command) || ''; const kind = detectGitCommandKind(command); const changedPaths = (kind === 'commit' || kind === 'push') ? listChangedFiles(kind) : []; const codeChanged = changedPaths.some((p) => !isDocsOnlyChange([p])); const sess = event.session_id || 'unknown'; const key = resolveReceiptKey(); const runtimeDir = join(homedir(), '.claude', 'runtime'); const gitCwd = process.cwd(); const plan = loadFrozenPlan({ sessionId: sess, runtimeDir }); const frozenPlanValid = !!(plan && verifyFrozenPlan(plan, key)); const changedSet = new Set(changedPaths); const criteria = []; const currentFingerprints = {}; if (frozenPlanValid) { for (const leaf of treeLeaves(plan.steps || [])) { const files = mapStepToFiles(leaf); if (!files || !changedSet.has(files.sourceFile)) continue; criteria.push({ id: leaf.criterion_id }); const read = (f) => { try { return readFileSync(join(gitCwd, f), 'utf-8'); } catch { return ''; } }; currentFingerprints[leaf.criterion_id] = codeFingerprint({ [files.sourceFile]: read(files.sourceFile), [files.testFile]: read(files.testFile) }); } } let greenRuns = []; const gp = join(runtimeDir, `criterion-greens-${sess}.json`); if (existsSync(gp)) { try { greenRuns = JSON.parse(readFileSync(gp, 'utf-8')); } catch { greenRuns = []; } } const action = canonicalAction('Bash', { command }); const escapeOpen = escapeGrantOpen(action, loadFloorEscapes(sess), loadConsumed(sess)); const r = decide({ toolName: event.tool_name, command, gate, key, codeChanged, frozenPlanValid, criteria, sealedCriterionIds: frozenPlanValid ? sealedIds(plan) : [], greenRuns, currentFingerprints, escapeOpen, changedPaths }); if (r.block) logGuardBlock(event, 'Level B Criterion', r.message); exitDecision({ block: r.block, message: r.block ? r.message : undefined }); } catch { exitDecision({ block: true, message: '[criterion-gate] внутренняя ошибка — fail-CLOSED' }); } } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();