Files
brain/tools/enforce-judge-gate.test.mjs
T
Дмитрий f87090702c feat: owner-seal только из терминального гранта (consent forgery B2)
Печать «одобрено владельцем» (owner-seal) теперь открывается ТОЛЬКО терминальным грантом
владельца (loadTerminalGrants), не chat floor_escape — Поза 1 для самого тяжёлого согласия.
Вычисление вынесено в тестируемую ownerSealOpenForEvent (источник грантов инъектируется);
sealTurnProd зовёт её с loadTerminalGrants. sealOnWiredGo (берёт ownerSealOpen параметром) и
его тесты не тронуты. Убран ставший неиспользуемым верхний импорт loadFloorEscapes
(escape-валве судьи берёт его динамически). Спека §B/§CRIT7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:17:46 +03:00

585 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tools/enforce-judge-gate.test.mjs
import { describe, it, expect } from 'vitest';
import {
decide, runJudgeGate, parseJudgeResponse, extractGate2Product, callJudgeModel,
buildVerdictEntry, logVerdictLine, warnJudgeUnavailable, runJudgeTurn,
sealOnWiredGo, SPEC_PATH_RE, bindingHashForJudge, bumpJudgeNoGo, ownerSealOpenForEvent,
} from './enforce-judge-gate.mjs';
import { sealableArtifact, judgedHashOf, sealablePlan, ownerSealAction } from './seal-orchestration.mjs';
import { planId } from './plan-lock.mjs';
describe('ownerSealOpenForEvent — owner-seal только из терминального гранта (Поза 1, #B)', () => {
const specEvent = (content) => ({ tool_input: { file_path: 'docs/superpowers/specs/x.md', content } });
it('терминальный грант на хеш тела спеки → true', () => {
const content = '## Цель\nтело\n';
const hash = judgedHashOf(sealableArtifact(content));
const grantsLoader = () => [{ action: ownerSealAction(hash), ts: 100 }];
expect(ownerSealOpenForEvent({ event: specEvent(content), sessionId: 's', grantsLoader, consumedLoader: () => [], now: 100 })).toBe(true);
});
it('нет терминального гранта (loader пуст — chat-грант отфильтрован в loadTerminalGrants) → false', () => {
const content = '## Цель\nтело\n';
expect(ownerSealOpenForEvent({ event: specEvent(content), sessionId: 's', grantsLoader: () => [], consumedLoader: () => [], now: 100 })).toBe(false);
});
it('грант на ЧУЖОЙ хеш → false', () => {
const content = '## Цель\nтело\n';
const grantsLoader = () => [{ action: ownerSealAction('другой-хеш'), ts: 100 }];
expect(ownerSealOpenForEvent({ event: specEvent(content), sessionId: 's', grantsLoader, consumedLoader: () => [], now: 100 })).toBe(false);
});
it('не spec/plan путь → false', () => {
const grantsLoader = () => [{ action: ownerSealAction('x'), ts: 100 }];
expect(ownerSealOpenForEvent({ event: { tool_input: { file_path: 'tools/x.mjs', content: 'y' } }, sessionId: 's', grantsLoader, consumedLoader: () => [], now: 100 })).toBe(false);
});
});
describe('bumpJudgeNoGo per task-id (Фаза 4 — счётчик на стэк спека+план)', () => {
const mem = () => { const s = {}; return { readFileSync: (p) => { if (!(p in s)) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } return s[p]; }, writeFileSync: (p, d) => { s[p] = d; }, mkdirSync: () => {} }; };
it('два разных task-id в одной сессии → независимые счётчики', () => {
const fsImpl = mem(); const dir = '/r';
expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(1);
expect(bumpJudgeNoGo({ taskId: 'task:B', blocked: true, fsImpl, dir })).toBe(1);
expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(2);
expect(bumpJudgeNoGo({ taskId: 'task:B', blocked: false, fsImpl, dir })).toBe(0);
expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(3);
});
it('SP2c-3: spec и plan одной задачи → независимые потолки (per-стадия §0/§6)', () => {
const fsImpl = mem(); const dir = '/r';
expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(1);
expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'plan', blocked: true, fsImpl, dir })).toBe(1);
expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(2);
expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'plan', blocked: false, fsImpl, dir })).toBe(0);
expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(3);
});
});
describe('bindingHashForJudge (Фаза 3 — какой хеш судья сверяет с mentor-GO)', () => {
it('gate1 (спека) → хеш артефакта спеки (тот же, чем судья печатает gate1)', () => {
const content = '# Спека\n## Цель\nцель спеки';
expect(bindingHashForJudge({ content, functionName: 'gate1' })).toBe(judgedHashOf(sealableArtifact(content)));
});
it('gate2 (план) → plan_hash из steps', () => {
const content = ['# План', '```steps-json', '[{"n":1,"op":"Edit","object":"x","ref":"D1"}]', '```'].join('\n');
expect(bindingHashForJudge({ content, functionName: 'gate2' })).toBe(planId(sealablePlan(content).steps));
});
it('gate1 и gate2 на одной спеке дают разные привязки (спека ≠ план)', () => {
const spec = '# Спека\n## Цель\nцель';
expect(bindingHashForJudge({ content: spec, functionName: 'gate1' })).not.toBe(planId(sealablePlan('```steps-json\n[{"n":1,"op":"Edit","object":"x","ref":"D1"}]\n```').steps));
});
});
const okText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee', delivery_honesty: 'ffffffff' }, objections: [] });
const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee', delivery_honesty: 'ffffffff' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] });
describe('runJudgeGate — delivery в промпте судьи', () => {
it('gate2 прокидывает пометку delivery в user-промпт', async () => {
const prompts = [];
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n**Delivery:** user-result\n## Цель\nцель\n```steps-json\n[{"op":"Edit","object":"tools/x.mjs","ref":"d1"}]\n```' } };
await runJudgeGate(ev, { judgeActiveImpl: () => true, apiKey: 'K', transport: async (p) => { prompts.push(p); return okText; } });
expect(prompts[0].user).toContain('ПОМЕТКА DELIVERY: user-result');
});
});
const planEv = () => ({ tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n## Цель\nцель плана тут\nшаг' } });
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(/inert|\$0/i);
});
it('shadow → allow (D28 тихий, логирует не блокирует)', () => {
const r = decide({ mode: 'shadow', verdict: { decision: 'NO-GO' } });
expect(r.block).toBe(false);
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);
});
it('live-block NO-GO → message несёт ПОЛНЫЙ текст возражения судьи (Фаза 1, Р2)', () => {
const verdict = { decision: 'NO-GO', verdict: { objections: [{ anchor: { ref: '§4 порог' }, severity: 'heavy' }] } };
const r = decide({ mode: 'live-block', verdict, floorBlocked: false });
expect(r.block).toBe(true);
expect(r.message).toContain('судья');
expect(r.message).toContain('§4 порог');
});
});
describe('runJudgeGate (async) — рубильник + детект + префетч (Δ-C degraded-allow)', () => {
const bashEv = { tool_name: 'Bash', tool_input: { command: 'ls' } };
it('не активен (нет флага/ключа) → wired:false, транспорт не зовётся', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => false, transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(planEv(), deps);
expect(r.decision).toBe('GO');
expect(r.wired).toBe(false);
expect(calls).toBe(0);
});
it('Способ B fail-safe: нет одобрения наставника (mentorApproved→false) → судья молчит (skip no_mentor_go), транспорт НЕ зовётся ($0)', async () => {
let calls = 0;
const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; }, mentorApproved: () => false });
expect(r.skip).toBe('no_mentor_go');
expect(r.wired).toBe(false);
expect(calls).toBe(0);
});
it('Способ B: есть одобрение наставника (mentorApproved→true) → судья судит (транспорт зовётся)', async () => {
let calls = 0;
const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; }, mentorApproved: () => true });
expect(calls).toBe(1);
expect(r.wired).toBe(true);
});
it('независимость (судья вслепую): промпт строится из плана, не несёт мнения наставника', async () => {
const prompts = [];
await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async (p) => { prompts.push(p); return okText; }, mentorApproved: () => true });
expect(prompts).toHaveLength(1);
expect(JSON.stringify(prompts[0])).not.toMatch(/наставник|mentor-go|одобрил/i);
});
it('mentorApproved не задан (наставник выключен) → судья судит как раньше (backward-compat)', async () => {
let calls = 0;
const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } });
expect(calls).toBe(1);
expect(r.wired).toBe(true);
});
// SP2c-2: судья J-side получает roundMemory (свои judge-замечания + J-доводы + diff) в промпт.
it('SP2c-2: roundMemoryImpl (plan) → J-память в промпте судьи (stage plan)', async () => {
const prompts = [];
await runJudgeGate(planEv(), {
judgeActiveImpl: () => true, apiKey: 'K', mentorApproved: () => true,
transport: async (p) => { prompts.push(p); return okText; },
roundMemoryImpl: ({ stage }) => ({ objections: [`память судьи ${stage}`] }),
});
expect(prompts).toHaveLength(1);
expect(prompts[0].user).toContain('память судьи plan');
});
it('SP2c-2: roundMemoryImpl (spec) → stage spec в промпте судьи', async () => {
const prompts = [];
const specEv = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nтекст' } };
await runJudgeGate(specEv, {
judgeActiveImpl: () => true, apiKey: 'K', mentorApproved: () => true,
transport: async (p) => { prompts.push(p); return okText; },
roundMemoryImpl: ({ stage }) => ({ objections: [`память судьи ${stage}`] }),
});
expect(prompts[0].user).toContain('память судьи spec');
});
it('активен, но не план (Bash) → wired:false, транспорт не зовётся ($0)', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(bashEv, deps);
expect(r.wired).toBe(false);
expect(calls).toBe(0);
});
it('активен, нет ROUTER_LLM_KEY → degraded allow (wired:false, unavailable:true, GO), транспорт не зовётся', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => true, apiKey: '', transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(planEv(), deps);
expect(calls).toBe(0);
expect(r.wired).toBe(false);
expect(r.unavailable).toBe(true);
expect(r.decision).toBe('GO');
});
it('активен + план + чистый вердикт → GO, wired:true, functionName=gate2', async () => {
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText };
const r = await runJudgeGate(planEv(), deps);
expect(r.wired).toBe(true);
expect(r.decision).toBe('GO');
expect(r.verdict.functionName).toBe('gate2');
});
it('активен + план + якорное тяжёлое NO → NO-GO', async () => {
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText };
const r = await runJudgeGate(planEv(), deps);
expect(r.decision).toBe('NO-GO');
});
});
describe('parseJudgeResponse — fail-closed', () => {
it('валидный JSON → {slots, objections}', () => {
const txt = JSON.stringify({ slots: { plan_soundness: 'план полный и связный' }, objections: [] });
const r = parseJudgeResponse(txt);
expect(r.slots.plan_soundness).toBe('план полный и связный');
expect(Array.isArray(r.objections)).toBe(true);
});
it('JSON в ```json-заборе → распарсен', () => {
const r = parseJudgeResponse('```json\n{"slots":{"x":"yyyyyyyy"},"objections":[]}\n```');
expect(r.slots.x).toBe('yyyyyyyy');
});
it('битый текст → {} (движок отвергнет по слотам)', () => {
expect(parseJudgeResponse('не json вовсе')).toEqual({});
});
it('массив/строка/пусто → {}', () => {
expect(parseJudgeResponse('[1,2]')).toEqual({});
expect(parseJudgeResponse('"строка"')).toEqual({});
expect(parseJudgeResponse('')).toEqual({});
});
it('objections не массив → нормализуется в []', () => {
const r = parseJudgeResponse(JSON.stringify({ slots: {}, objections: 'нет' }));
expect(r.objections).toEqual([]);
});
});
describe('extractGate2Product — детект плана (Write-only, Δ-A) + извлечение', () => {
const planPath = 'docs/superpowers/plans/2026-06-09-foo.md';
it('Write плана → shouldJudge, product=content, functionName=gate2, goal, cards=[]', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: planPath, content: '# План\n## Цель\nсделать X\nшаги...' } };
const r = extractGate2Product(ev);
expect(r.shouldJudge).toBe(true);
expect(r.functionName).toBe('gate2');
expect(r.product).toContain('сделать X');
expect(r.goal).toContain('сделать X');
expect(r.cards).toEqual([]);
});
it('Write плана с обратными слэшами → распознаётся', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs\\superpowers\\plans\\x.md', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(true);
});
it('Edit плана → shouldJudge:false (Δ-A: фрагмент, не весь план)', () => {
const ev = { tool_name: 'Edit', tool_input: { file_path: planPath, new_string: 'новый шаг плана' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('MultiEdit плана → shouldJudge:false (Δ-A)', () => {
const ev = { tool_name: 'MultiEdit', tool_input: { file_path: planPath, edits: [{ old_string: 'a', new_string: 'b' }] } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('не-план путь (Write) → shouldJudge:false', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('спека (specs/) → shouldJudge:false (это Гейт-1, не Гейт-2)', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x.md', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('Bash → shouldJudge:false', () => {
expect(extractGate2Product({ tool_name: 'Bash', tool_input: { command: 'ls' } }).shouldJudge).toBe(false);
});
it('пустое/битое событие → shouldJudge:false', () => {
expect(extractGate2Product(null).shouldJudge).toBe(false);
expect(extractGate2Product({}).shouldJudge).toBe(false);
});
});
describe('callJudgeModel — транспорт + парс + спенд-гейт (Δ-B unavailable)', () => {
const promptArgs = { product: 'p', goal: 'g', cards: [] };
const lenses = ['plan_soundness'];
it('нет apiKey → {unavailable:true}, транспорт НЕ зовётся', async () => {
let calls = 0;
const transport = async () => { calls++; return '{}'; };
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: '', transport });
expect(calls).toBe(0);
expect(r).toEqual({ unavailable: true, cause: 'no_key' });
});
it('есть apiKey → транспорт зовётся, ответ распарсен', async () => {
let calls = 0;
const transport = async (pm, opts) => {
calls++;
expect(opts.apiKey).toBe('K');
expect(opts.perAttemptTimeoutMs).toBe(300_000);
expect(pm.system).toMatch(/судья/i);
return JSON.stringify({ slots: { plan_soundness: 'ок-ок-ок' }, objections: [] });
};
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(calls).toBe(1);
expect(r.slots.plan_soundness).toBe('ок-ок-ок');
});
it('транспорт бросил → {unavailable:true} (не NO-GO, fail-closed против over-block)', async () => {
const transport = async () => { throw new Error('net'); };
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(r).toEqual({ unavailable: true, cause: 'transport_error', errorType: 'other' });
});
it('транспорт вернул мусор → {} (движок отвергнет → NO-GO)', async () => {
const transport = async () => 'не json';
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(r).toEqual({});
});
});
describe('shadow-лог вердиктов + warn (Δ-D)', () => {
it('buildVerdictEntry: вердикт+событие → entry {kind, functionName, decision, at}', () => {
const e = buildVerdictEntry({ decision: 'GO', verdict: { functionName: 'gate2', decision: 'GO' } }, 123);
expect(e.kind).toBe('verdict');
expect(e.functionName).toBe('gate2');
expect(e.decision).toBe('GO');
expect(e.at).toBe(123);
});
it('logVerdictLine: JSONL-строка дописывается в файл (инъекция fs)', () => {
const writes = [];
const fsImpl = { appendFileSync: (p, s) => writes.push([p, s]), mkdirSync: () => {} };
logVerdictLine({ kind: 'verdict', decision: 'GO' }, { fsImpl, dir: '/r' });
expect(writes).toHaveLength(1);
expect(writes[0][0]).toMatch(/judge-verdicts\.jsonl$/);
expect(writes[0][1]).toMatch(/"decision":"GO"/);
expect(writes[0][1].endsWith('\n')).toBe(true);
});
it('logVerdictLine: ошибка fs проглатывается (best-effort, не бросает)', () => {
const fsImpl = { appendFileSync: () => { throw new Error('io'); }, mkdirSync: () => {} };
expect(() => logVerdictLine({ decision: 'GO' }, { fsImpl, dir: '/r' })).not.toThrow();
});
it('warnJudgeUnavailable: дописывает judge_unavailable строку (инъекция fs)', () => {
const writes = [];
const fsImpl = { appendFileSync: (p, s) => writes.push([p, s]), mkdirSync: () => {} };
warnJudgeUnavailable({ tool_name: 'Write' }, { fsImpl, dir: '/r' });
expect(writes).toHaveLength(1);
expect(writes[0][1]).toMatch(/judge_unavailable/);
expect(writes[0][1].endsWith('\n')).toBe(true);
});
});
describe('runJudgeTurn — режим-aware (Δ-D inert/shadow/live-block, без I/O)', () => {
it('inert → allow, прогона нет, лога нет', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'inert', judgeActiveImpl: () => false, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(0);
});
it('shadow + план → прогон + лог вердикта, allow (D28 не блокирует)', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(1);
expect(logged[0].decision).toBe('NO-GO');
});
it('live-block + GO → allow + лог', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(1);
});
it('live-block + NO-GO → block', async () => {
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} });
expect(r.block).toBe(true);
});
it('live-block + NO-GO → полный текст возражения судьи доходит до контроллера (Фаза 1, Р2)', async () => {
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} });
expect(r.block).toBe(true);
expect(r.message).toContain('§1'); // дословный anchor.ref возражения судьи
expect(r.message).toContain('судья');
});
it('live-block + не-план → allow (нечего судить, $0)', async () => {
const r = await runJudgeTurn({ tool_name: 'Bash', tool_input: { command: 'ls' } }, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: () => {} });
expect(r.block).toBe(false);
});
it('live-block + unavailable (§9): warnImpl вызван, не verdict-лог, контроллер ИНФОРМИРОВАН (block:true degraded «не дозвонился»)', async () => {
const logged = [];
let warned = 0;
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: '', transport: async () => okText, logImpl: (e) => logged.push(e), warnImpl: () => { warned++; } });
expect(r.block).toBe(true);
expect(r.degraded).toBe(true);
expect(r.message).toMatch(/не смог дозвониться|недоступен/i);
expect(logged).toHaveLength(0);
expect(warned).toBe(1);
});
it('shadow + unavailable → тихо (block:false, D28 не блокирует), warnImpl вызван', async () => {
let warned = 0;
const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => true, apiKey: '', transport: async () => okText, warnImpl: () => { warned++; } });
expect(r.block).toBe(false);
expect(warned).toBe(1);
});
it('live-block + заход судьи бросил → ВИДИМЫЙ degraded (block + degraded), предупреждение вызвано (фикс silent-swallow)', async () => {
let warned = 0;
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => { throw new Error('boom'); }, logImpl: () => {}, warnImpl: () => { warned++; } });
expect(r.block).toBe(true);
expect(r.degraded).toBe(true);
expect(warned).toBe(1);
});
it('shadow + заход судьи бросил → allow (D28), но предупреждение вызвано (срыв виден, не нем)', async () => {
let warned = 0;
const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => { throw new Error('boom'); }, warnImpl: () => { warned++; } });
expect(r.block).toBe(false);
expect(warned).toBe(1);
});
});
describe('sealed-plan production Task 5 — seal on wired GO (SPEC_PATH_RE + sealOnWiredGo)', () => {
it('SPEC_PATH_RE matches docs/superpowers/specs/*.md, not plans', () => {
expect(SPEC_PATH_RE.test('docs/superpowers/specs/2026-06-09-x-design.md')).toBe(true);
expect(SPEC_PATH_RE.test('docs/superpowers/plans/x.md')).toBe(false);
});
it('sealOnWiredGo: spec-Write + real GO → sealArtifact invoked with judgeMode', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nt' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: (a) => { calls.push(['artifact', a.judgeMode]); return { sealed: true }; }, persistArtifact: () => {} } });
expect(calls[0]).toEqual(['artifact', 'shadow']);
});
it('sealOnWiredGo: plan-Write + real GO → sealPlan invoked with current artifact', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '```steps-json\n[{"op":"Edit","object":"a","ref":"r"}]\n```' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'GO' }, judgeMode: 'live-block',
deps: { loadCurrentArtifact: () => ({ artifact_id: 'AID' }), sealPlan: (a) => { calls.push(['plan', a.currentArtifact.artifact_id, a.judgeMode]); return { sealed: true }; }, persistPlan: () => {} } });
expect(calls[0]).toEqual(['plan', 'AID', 'live-block']);
});
it('sealOnWiredGo: degraded GO (wired:false) → no seal call', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: 'x' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: false, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: () => { calls.push('a'); return {}; } } });
expect(calls).toEqual([]);
});
});
// T3 активации наставника (решение владельца 2026-06-12): PLAN_PATH_RE экспортируется —
// единый источник «что есть план» для судьи И наставника. Импорт внизу перед describe
// (ESM hoisting).
import { PLAN_PATH_RE } from './enforce-judge-gate.mjs';
describe('PLAN_PATH_RE — экспорт единого источника «что есть план»', () => {
it('матчит docs/superpowers/plans/*.md и НЕ матчит specs', () => {
expect(PLAN_PATH_RE.test('docs/superpowers/plans/2026-06-12-x.md')).toBe(true);
expect(PLAN_PATH_RE.test('docs\\superpowers\\plans\\x.md')).toBe(true);
expect(PLAN_PATH_RE.test('docs/superpowers/specs/x.md')).toBe(false);
});
});
// «Оба строго» (решение владельца 2026-06-12): судья без deps.apiKey берёт ТОЛЬКО
// ROUTER_JUDGE_LLM_KEY — общий ROUTER_LLM_KEY НЕ подхватывается (разделение счетов).
describe('строгий LLM-ключ судьи (env-контроль)', () => {
it('нет своего ключа, есть общий → unavailable, transport НЕ вызван', async () => {
const savedJudge = process.env.ROUTER_JUDGE_LLM_KEY;
const savedCommon = process.env.ROUTER_LLM_KEY;
delete process.env.ROUTER_JUDGE_LLM_KEY;
process.env.ROUTER_LLM_KEY = 'common-key-должен-игнорироваться';
let transportCalls = 0;
try {
const r = await runJudgeGate(planEv(), {
judgeActiveImpl: () => true,
transport: async () => { transportCalls++; return okText; },
});
expect(r.unavailable).toBe(true);
expect(transportCalls).toBe(0);
} finally {
if (savedJudge === undefined) delete process.env.ROUTER_JUDGE_LLM_KEY; else process.env.ROUTER_JUDGE_LLM_KEY = savedJudge;
if (savedCommon === undefined) delete process.env.ROUTER_LLM_KEY; else process.env.ROUTER_LLM_KEY = savedCommon;
}
});
});
// W-2 (sharp-edges 2026-06-12, характеризация в ПАРНОМ файле): контракт T6 —
// mentorGate получает РОВНО {content} записываемого плана (корень репо для C-3
// гейт берёт из события сам через repoRootOf, не из аргумента).
describe('T6: контракт mentorGate({content})', () => {
it('mentorGate вызывается с content записываемого плана', () => {
let got = null;
sealOnWiredGo({
event: { tool_input: { file_path: 'docs/superpowers/plans/t.md', content: 'ПЛАН-ТЕКСТ' } },
verdict: { wired: true, decision: 'GO' }, judgeMode: 'shadow',
deps: {
key: 'k', sealPlan: () => ({ sealed: true, seal: {} }), loadCurrentArtifact: () => ({ artifact_id: 'A' }), persistPlan: () => {},
mentorGate: (args) => { got = args; return { pass: true, reason: 'ok' }; },
},
});
expect(got).toEqual({ content: 'ПЛАН-ТЕКСТ' });
});
});
// M7 наблюдаемость degraded судьи (ремонт 2026-06-13): «судья недоступен» больше не
// безымянная degraded-строка. callJudgeModel различает no_key (мгновенно, $0) от
// transport_error:<classifyLLMError>; причина протекает в вердикт → warnJudgeUnavailable
// (+ at) и в seal-запись (+ at). Закрывает слепоту «нельзя понять, почему degraded».
describe('M7 наблюдаемость degraded — классификация причины + timestamp', () => {
const product = { product: 'p', goal: 'g', cards: [] };
it('callJudgeModel: нет ключа → unavailable + cause:no_key, транспорт не зовётся', async () => {
let calls = 0;
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: [], promptArgs: product, apiKey: '', transport: async () => { calls++; return okText; } });
expect(r.unavailable).toBe(true);
expect(r.cause).toBe('no_key');
expect(calls).toBe(0);
});
it('callJudgeModel: транспорт бросил → cause:transport_error + errorType классифицирован', async () => {
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: [], promptArgs: product, apiKey: 'K', transport: async () => { throw new Error('socket hang up'); } });
expect(r.unavailable).toBe(true);
expect(r.cause).toBe('transport_error');
expect(r.errorType).toBe('econnreset');
});
it('runJudgeGate: degraded (нет ключа) пробрасывает cause в вердикт', async () => {
const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: '', transport: async () => okText });
expect(r.unavailable).toBe(true);
expect(r.cause).toBe('no_key');
});
it('runJudgeGate: degraded (бросок 5xx) пробрасывает cause+errorType', async () => {
const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { throw new Error('Router LLM 529: overloaded'); } });
expect(r.unavailable).toBe(true);
expect(r.cause).toBe('transport_error');
expect(r.errorType).toBe('http_5xx');
});
it('warnJudgeUnavailable: пишет cause/error_type/at в строку', () => {
let written = '';
const fsImpl = { mkdirSync: () => {}, appendFileSync: (_p, s) => { written += s; } };
warnJudgeUnavailable({}, { cause: 'transport_error', errorType: 'timeout', nowMs: 999, fsImpl, dir: '/r' });
const o = JSON.parse(written.trim());
expect(o.kind).toBe('judge_unavailable');
expect(o.cause).toBe('transport_error');
expect(o.error_type).toBe('timeout');
expect(o.at).toBe(999);
});
it('runJudgeTurn: degraded → warnImpl получает cause/errorType вердикта + nowMs', async () => {
const seen = [];
const r = await runJudgeTurn(planEv(), {
mode: 'shadow', judgeActiveImpl: () => true, apiKey: 'K',
transport: async () => { throw new Error('Router LLM 503'); },
logImpl: () => {}, warnImpl: (_e, opts) => seen.push(opts), sealLogImpl: () => {}, nowMs: 123,
});
expect(r.block).toBe(false);
expect(seen).toHaveLength(1);
expect(seen[0].cause).toBe('transport_error');
expect(seen[0].errorType).toBe('http_5xx');
expect(seen[0].nowMs).toBe(123);
});
it('runJudgeTurn: degraded → seal-запись несёт cause + at', async () => {
const seals = [];
await runJudgeTurn(planEv(), {
mode: 'shadow', judgeActiveImpl: () => true, apiKey: '',
transport: async () => okText,
logImpl: () => {}, warnImpl: () => {}, sealLogImpl: (e) => seals.push(e), nowMs: 777,
});
const degraded = seals.find((s) => s.wired === false && s.decision === 'GO');
expect(degraded).toBeTruthy();
expect(degraded.cause).toBe('no_key');
expect(degraded.at).toBe(777);
});
});
// SP3-b: owner-seal проводка — оживление печати при NO-GO/degraded + пропуск mentor-gate.
describe('SP3-b owner-seal проводка (sealOnWiredGo + runJudgeTurn)', () => {
it('sealOnWiredGo: NO-GO + ownerSealOpen → sealArtifact зовётся (override перевешивает)', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nt' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block', ownerSealOpen: true,
deps: { sealArtifact: () => { calls.push('artifact'); return { sealed: true }; }, persistArtifact: () => {} } });
expect(calls).toEqual(['artifact']);
});
it('sealOnWiredGo: plan + ownerSealOpen → mentor freeze-gate ПРОПУСКАЕТСЯ, печать встаёт', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '```steps-json\n[{"op":"Edit","object":"a","ref":"r"}]\n```' } };
let mentorCalled = false;
const r = sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block', ownerSealOpen: true,
deps: { loadCurrentArtifact: () => ({ artifact_id: 'AID' }), sealPlan: () => ({ sealed: true, seal: {} }), persistPlan: () => {},
mentorGate: () => { mentorCalled = true; return { pass: false, reason: 'x' }; } } });
expect(mentorCalled).toBe(false);
expect(r.sealed).toBe(true);
});
it('sealOnWiredGo: NO-GO без owner-seal → печати нет (sealArtifact не зовётся)', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: 'x' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block',
deps: { sealArtifact: () => { calls.push('a'); return {}; } } });
expect(calls).toEqual([]);
});
it('runJudgeTurn: wired:false (degraded) + judged → onWiredSeal ВСЁ РАВНО зовётся (оживление проводки)', async () => {
const seen = [];
await runJudgeTurn(planEv(), {
mode: 'shadow', judgeActiveImpl: () => true, apiKey: '',
transport: async () => okText,
logImpl: () => {}, warnImpl: () => {}, sealLogImpl: () => {}, nowMs: 1,
onWiredSeal: (ev, v) => { seen.push(v && v.wired); return { sealed: false }; },
});
expect(seen).toEqual([false]);
});
});