Files
brain/tools/enforce-judge-gate.test.mjs
T
Дмитрий 8c4c50cfb3 test(brain-config): обновить assertions под deepseek-v4-pro миграцию
3 пре-существующих красных от ba10068: router-config модель + 2 timeout-assert HEAVY_LLM_TIMEOUT_MS 90000 to 300000. Parse-движок не тронут.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:48:06 +03:00

473 lines
28 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,
} from './enforce-judge-gate.mjs';
import { sealableArtifact, judgedHashOf, sealablePlan } from './seal-orchestration.mjs';
import { planId } from './plan-lock.mjs';
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);
});
});
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' }, objections: [] });
const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] });
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);
});
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);
});
});
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);
});
});