diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index 0bc59891..5efe0b8c 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -1,32 +1,58 @@ #!/usr/bin/env node /** - * enforce-judge-gate — тонкая хук-обёртка судьи (Машина 4). НЕ движок: движок — - * judge-orchestrator + judge-engine + пол судьи (4-A..4-F). Обёртка ИНЕРТНА, пока - * владелец не включит судью (флаг + ключ + регистрация в settings.json — шаг владельца). - * Даже включённая, по D28 «сперва тихо» она НЕ блокирует — лишь помечает теневое решение; - * живой блокирующий режим подключается отдельным шагом владельца. $0 пока выключен. + * enforce-judge-gate — тонкая хук-обёртка судьи (Машина 4). НЕ движок (движок — + * judge-orchestrator + judge-engine + пол судьи 4-A..4-F). Гейтит расход рубильником + * (judgeActive: флаг ROUTER_MENTOR_JUDGE_ENABLED + ключ) → $0 пока выключен. Режим + * (judgeGateMode): inert ($0) / shadow (active, логирует, НЕ блокирует — D28) / live-block + * (active + MODE=block, блокирует на NO-GO; пол перевешивает через finalGate). * * Регистрировать в settings.json нужно ИМЕННО эту обёртку (не движок) — у движка нет - * рубильника $0; обёртка гейтит расход флагом+ключом (паттерн llm-judge v4). + * рубильника $0; обёртка гейтит флагом+ключом+режимом (паттерн llm-judge v4). + * + * §8 (шаг ВЛАДЕЛЬЦА, последняя фаза): реальный llmCall-транспорт + извлечение «продукта на + * суд» подключаются в runJudgeGate владельцем. До подключения — нейтральный GO (flip mode=block + * без транспорта НЕ кирпичит сессию). Рекомендация §11: обкатка в shadow перед block. */ -import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; -import { judgeActive } from './judge-gate-config.mjs'; +import { readStdin, parseEventJson, exitDecision, exitDisciplineDecision } from './enforce-hook-helpers.mjs'; +import { judgeGateMode } from './judge-gate-config.mjs'; +import { finalGate } from './judge-orchestrator.mjs'; -/** Чистое решение обёртки. Никогда не блокирует на этом этапе (D28 тихий режим). */ -export function decide({ active }) { - if (!active) return { block: false, reason: 'judge disabled ($0) — рубильник владельца выключен' }; - return { block: false, reason: 'shadow (D28) — живой блок подключается шагом владельца (live wiring)' }; +/** + * Чистое решение обёртки. inert/shadow → allow. live-block → finalGate(вердикт, пол): + * судья GO И пол чист → allow; иначе block. Битый/пустой вердикт → NO-GO (сомнение→блок). + */ +export function decide({ mode, verdict, floorBlocked = false } = {}) { + if (mode !== 'live-block') { + return { block: false, reason: mode === 'inert' ? 'judge inert ($0)' : 'shadow (D28) — судья логирует, не блокирует' }; + } + const decision = verdict && verdict.decision === 'GO' ? 'GO' : 'NO-GO'; + const gate = finalGate({ judgeDecision: decision, floorBlocked }); + return gate === 'allow' + ? { block: false, reason: 'live-block: судья GO + пол чист' } + : { block: true, message: `[judge-gate] live-block: судья=${decision}, пол=${floorBlocked} → блок` }; +} + +/** + * §8 owner-wiring seam: реальный вызов судьи (llmCall-транспорт + извлечение продукта/цели/ + * карточек из события) подключает ВЛАДЕЛЕЦ (последняя фаза). До подключения — нейтральный GO, + * wired:false, чтобы перевод mode=block без транспорта НЕ кирпичил рабочий цикл. + */ +export function runJudgeGate(_event) { + return { decision: 'GO', wired: false }; } async function main() { + let event, mode; try { - parseEventJson(await readStdin()); // событие читаем, но пока не используем (тихий режим) - const active = judgeActive(); - const r = decide({ active }); - exitDecision({ block: r.block }); - } catch { - exitDecision({ block: false }); // fail-open пока инертна; живой режим будет fail-closed - } + event = parseEventJson(await readStdin()); + mode = judgeGateMode(); + } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe allow ($0) + if (mode !== 'live-block') { exitDecision({ block: false }); return; } // inert/shadow → allow + // live-block (судья жив): fail-CLOSE — ошибка вычисления вердикта → блок (молчать нельзя). + await exitDisciplineDecision( + () => decide({ mode, verdict: runJudgeGate(event), floorBlocked: false }), + { label: 'enforce-judge-gate' }, + ); } import { fileURLToPath } from 'node:url'; diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index 2c9f51ed..94a66d3e 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -1,16 +1,39 @@ // tools/enforce-judge-gate.test.mjs import { describe, it, expect } from 'vitest'; -import { decide } from './enforce-judge-gate.mjs'; +import { decide, runJudgeGate } from './enforce-judge-gate.mjs'; -describe('enforce-judge-gate.decide (обёртка судьи; инертна до включения владельцем)', () => { - it('судья выключен → пропуск ($0, ничего не блокирует)', () => { - const r = decide({ event: { tool_name: 'Write' }, active: false }); +describe('enforce-judge-gate decide (М7 Фаза 7 §8) — mode-aware + finalGate', () => { + it('inert → allow ($0)', () => { + const r = decide({ mode: 'inert' }); expect(r.block).toBe(false); - expect(r.reason).toMatch(/disabled|выключ|\$0/i); + expect(r.reason).toMatch(/inert|\$0/i); }); - it('судья включён → тихий режим D28 (пропуск + пометка, живой блок — шаг владельца)', () => { - const r = decide({ event: { tool_name: 'Write' }, active: true }); + it('shadow → allow (D28 тихий, логирует не блокирует)', () => { + const r = decide({ mode: 'shadow', verdict: { decision: 'NO-GO' } }); expect(r.block).toBe(false); - expect(r.reason).toMatch(/shadow|тих|D28|wiring/i); + expect(r.reason).toMatch(/shadow|D28/i); + }); + it('live-block + судья GO + пол чист → allow', () => { + expect(decide({ mode: 'live-block', verdict: { decision: 'GO' }, floorBlocked: false }).block).toBe(false); + }); + it('live-block + судья NO-GO → block', () => { + const r = decide({ mode: 'live-block', verdict: { decision: 'NO-GO' }, floorBlocked: false }); + expect(r.block).toBe(true); + }); + it('live-block + пол заблокировал (floorBlocked=true) → block даже при судье GO (пол перевешивает)', () => { + const r = decide({ mode: 'live-block', verdict: { decision: 'GO' }, floorBlocked: true }); + expect(r.block).toBe(true); + }); + it('live-block + битый/пустой вердикт → block (сомнение → NO-GO, fail-closed)', () => { + expect(decide({ mode: 'live-block', verdict: null, floorBlocked: false }).block).toBe(true); + expect(decide({ mode: 'live-block', verdict: {}, floorBlocked: false }).block).toBe(true); + }); +}); + +describe('runJudgeGate seam (владелец подключает реальный транспорт §8)', () => { + it('по умолчанию нейтральный GO + wired:false (flip без транспорта НЕ кирпичит)', () => { + const r = runJudgeGate({ tool_name: 'Bash' }); + expect(r.decision).toBe('GO'); + expect(r.wired).toBe(false); }); });