f87090702c
Печать «одобрено владельцем» (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>
585 lines
36 KiB
JavaScript
585 lines
36 KiB
JavaScript
// 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]);
|
||
});
|
||
});
|