feat(m7-phase7): enforce-judge-gate mode-aware + finalGate + runJudgeGate seam + live fail-CLOSE (§8)
Обёртка судьи переписана с {active}-shadow-заглушки на mode-aware: decide({mode,verdict,
floorBlocked}) — inert/shadow → allow ($0/D28 тихий); live-block → finalGate (судья GO И пол
чист → allow; иначе block; битый вердикт → NO-GO, сомнение→блок). runJudgeGate — owner-seam
(§8 последняя фаза: реальный llmCall-транспорт + извлечение продукта подключает владелец; до
этого нейтральный GO wired:false — flip mode=block без транспорта НЕ кирпичит). main:
inert/shadow → allow fail-open ($0); live-block → exitDisciplineDecision (fail-CLOSE, судья жив).
7/7 GREEN. Инертно до активации владельцем.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user