abf2060328
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
6.0 KiB
JavaScript
95 lines
6.0 KiB
JavaScript
#!/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; }
|
||
if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) return exitDecision({ block: false });
|
||
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();
|