8c4c50cfb3
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>
231 lines
14 KiB
JavaScript
231 lines
14 KiB
JavaScript
// tools/enforce-mentor-on-plan-write.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { runMentorOnPlanWrite, buildLlmCall, decideMentorObjection } from './enforce-mentor-on-plan-write.mjs';
|
||
|
||
const PLAN_MD = [
|
||
'# План', '',
|
||
// ref обязателен в каждом шаге (plan-steps-parse.mjs:42, closed-door SE-1)
|
||
'```steps-json', '[{"n":1,"op":"Edit","object":"tools/x.mjs","ref":"D1"}]', '```', '',
|
||
'```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"tools/x.mjs:1","anchor":"якорь-подстрока"}]', '```',
|
||
].join('\n');
|
||
const GOOD_VERDICT = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' };
|
||
const planEvent = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/2026-06-12-t.md', content: PLAN_MD }, session_id: 'S1' };
|
||
|
||
// real-test маркер региона правки (фикстура выше используется ассертами ниже):
|
||
it('фикстура PLAN_MD несёт валидный steps-json (ref обязателен, SE-1)', () => {
|
||
expect(PLAN_MD).toMatch(/"ref":"D1"/);
|
||
});
|
||
|
||
function deps(over = {}) {
|
||
const persisted = { journal: null, verdict: null, taskId: null };
|
||
return {
|
||
persisted,
|
||
mentorActiveImpl: () => true,
|
||
llmCall: async () => GOOD_VERDICT,
|
||
loadJournalImpl: () => ({ entries: [], headSig: null }),
|
||
persistJournalImpl: (j) => { persisted.journal = j; },
|
||
persistVerdictImpl: (r) => { persisted.verdict = r; },
|
||
loadTaskIdImpl: () => null,
|
||
persistTaskIdImpl: (id) => { persisted.taskId = id; },
|
||
journalKey: 'k',
|
||
graphSectionImpl: () => null,
|
||
classifyImpl: async () => ({ recommended_chain: [] }),
|
||
registryImpl: () => ({}),
|
||
...over,
|
||
};
|
||
}
|
||
|
||
describe('runMentorOnPlanWrite (обёртка-производитель W7)', () => {
|
||
it('inert → no-op $0 (llmCall не зовётся)', async () => {
|
||
let called = false;
|
||
const d = deps({ mentorActiveImpl: () => false, llmCall: async () => { called = true; return GOOD_VERDICT; } });
|
||
const r = await runMentorOnPlanWrite(planEvent, d);
|
||
expect(r.ran).toBe(false);
|
||
expect(called).toBe(false);
|
||
});
|
||
it('не план-Write (Edit / чужой путь) → no-op', async () => {
|
||
const d = deps();
|
||
expect((await runMentorOnPlanWrite({ ...planEvent, tool_name: 'Edit' }, d)).ran).toBe(false);
|
||
expect((await runMentorOnPlanWrite({ tool_name: 'Write', tool_input: { file_path: 'docs/x.md', content: 'y' }, session_id: 'S1' }, d)).ran).toBe(false);
|
||
});
|
||
it('план-Write → вердикт произведён, binding planHash, журнал и task-id персистнуты', async () => {
|
||
const d = deps();
|
||
const r = await runMentorOnPlanWrite(planEvent, d);
|
||
expect(r.ran).toBe(true);
|
||
expect(r.ok).toBe(true);
|
||
expect(d.persisted.verdict.verdict.plan_hash).toBe(d.persisted.verdict.planHash); // нах.F4
|
||
expect(d.persisted.journal.entries).toHaveLength(1); // ДР-6 запись круга
|
||
expect(d.persisted.taskId).toMatch(/^task:/); // ✅O17
|
||
});
|
||
it('план без steps-json → ran:false с reason (вердикт не фабрикуется)', async () => {
|
||
const noSteps = { ...planEvent, tool_input: { ...planEvent.tool_input, content: '# без шагов' } };
|
||
const r = await runMentorOnPlanWrite(noSteps, deps());
|
||
expect(r.ran).toBe(false);
|
||
expect(r.reason).toMatch(/steps/i);
|
||
});
|
||
it('сбой транспорта → ran:true, wired:false (SE-R6-6), вердикт-отказ персистнут', async () => {
|
||
const d = deps({ llmCall: async () => { throw new Error('сеть'); } });
|
||
const r = await runMentorOnPlanWrite(planEvent, d);
|
||
expect(r.ran).toBe(true);
|
||
expect(r.wired).toBe(false);
|
||
expect(d.persisted.verdict.wired).toBe(false);
|
||
});
|
||
// Фаза 3 (Р6): наставник судит и СПЕКУ (отдельный spec-путь), binding к хешу артефакта спеки.
|
||
const SPEC_MD = [
|
||
'# Спека', '## Цель', 'описание решения', '',
|
||
'```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"tools/x.mjs:1","anchor":"якорь-подстрока"}]', '```',
|
||
].join('\n');
|
||
const specEvent = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/2026-06-13-x.md', content: SPEC_MD }, session_id: 'S1' };
|
||
it('запись СПЕКИ → наставник судит спеку, вердикт произведён, binding specHash, verdict персистнут', async () => {
|
||
const d = deps();
|
||
const r = await runMentorOnPlanWrite(specEvent, d);
|
||
expect(r.ran).toBe(true);
|
||
expect(r.ok).toBe(true);
|
||
expect(typeof r.planHash).toBe('string');
|
||
expect(r.planHash.length).toBeGreaterThan(0);
|
||
expect(d.persisted.verdict.verdict.plan_hash).toBe(r.planHash); // binding к артефакту спеки
|
||
});
|
||
it('запись СПЕКИ: чужой путь (другой .md) → no-op', async () => {
|
||
expect((await runMentorOnPlanWrite({ tool_name: 'Write', tool_input: { file_path: 'docs/x.md', content: SPEC_MD }, session_id: 'S1' }, deps())).ran).toBe(false);
|
||
});
|
||
|
||
// W-3 (sharp-edges 2026-06-12): в промпт наставника идут переговоры ТОЛЬКО текущей
|
||
// задачи — чужие task_id из общей цепи журнала не текут между задачами.
|
||
it('W-3: negotiationLog фильтруется по task_id текущей задачи', async () => {
|
||
let capturedUser = null;
|
||
const mkEntry = (taskId, utterance) => ({ seq: 1, ts: 1, prev_hash: 'x', chain_hash: 'y',
|
||
payload: { task_id: taskId, round: 1, side: 'mentor', utterance, justification: 'j' } });
|
||
const d = deps({
|
||
loadTaskIdImpl: () => 'task:MINE',
|
||
loadJournalImpl: () => ({ entries: [mkEntry('task:MINE', 'СВОЁ-СООБЩЕНИЕ'), mkEntry('task:OTHER', 'ЧУЖОЕ-СООБЩЕНИЕ')], headSig: 's' }),
|
||
llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GOOD_VERDICT; },
|
||
});
|
||
const r = await runMentorOnPlanWrite(planEvent, d);
|
||
expect(r.ran).toBe(true);
|
||
expect(capturedUser).toMatch(/СВОЁ-СООБЩЕНИЕ/);
|
||
expect(capturedUser).not.toMatch(/ЧУЖОЕ-СООБЩЕНИЕ/);
|
||
});
|
||
|
||
// Мерж (Task 8): план-Write парсит объявленные скилы и зовёт classifyImpl (мозг роутера).
|
||
it('runMentorOnPlanWrite (план): парсит скилы + зовёт classifyImpl', async () => {
|
||
let classifyCalled = false;
|
||
const PLAN = ['# План', '```skills-json', '["executing-plans"]', '```', '## Цель', 'чинить', '```steps-json', '[{"n":1,"op":"Edit","object":"x","ref":"D1"}]', '```', '```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"x:1","anchor":"я"}]', '```'].join('\n');
|
||
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/2026-06-13-t.md', content: PLAN }, session_id: 'S1' };
|
||
const d = deps({ classifyImpl: async () => { classifyCalled = true; return { recommended_chain: ['systematic-debugging'] }; } });
|
||
const r = await runMentorOnPlanWrite(ev, d);
|
||
expect(r.ran).toBe(true);
|
||
expect(classifyCalled).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => {
|
||
const noGo = { ran: true, wired: true, ok: false, reason: 'шаг 2 трогает файл X без обоснования', verdict: { objections: [] } };
|
||
it('NO-GO (n<3) → block:true + полный текст замечания доходит до контроллера', () => {
|
||
const d = decideMentorObjection({ res: noGo, planContent: '# план', n: 1 });
|
||
expect(d.block).toBe(true);
|
||
expect(d.message).toContain('наставник');
|
||
expect(d.message).toContain('шаг 2 трогает файл X без обоснования');
|
||
});
|
||
it('NO-GO на 3-м заходе (эскалация) → block:true + карточка арбитража с дословным замечанием', () => {
|
||
const d = decideMentorObjection({ res: noGo, planContent: '# план', n: 3 });
|
||
expect(d.block).toBe(true);
|
||
expect(d.message).toContain('Что меняет выбор'); // маркер карточки арбитража
|
||
expect(d.message).toContain('шаг 2 трогает файл X без обоснования'); // дословное замечание сохранено
|
||
});
|
||
it('GO (wired:true, ok:true, decision=GO) → block:false + recordMentorGo:true (наставник одобрил)', () => {
|
||
const d = decideMentorObjection({ res: { ran: true, wired: true, ok: true, planHash: 'PH', verdict: { decision: 'GO' } }, planContent: '', n: 0 });
|
||
expect(d.block).toBe(false);
|
||
expect(d.recordMentorGo).toBe(true);
|
||
});
|
||
it('NO-GO → recordMentorGo:false (одобрения нет)', () => {
|
||
expect(decideMentorObjection({ res: noGo, planContent: '# план', n: 1 }).recordMentorGo).toBe(false);
|
||
});
|
||
it('degraded (wired:false — спека §9) → block:true + degraded-сообщение, recordMentorGo:false', () => {
|
||
const d = decideMentorObjection({ res: { ran: true, wired: false, ok: false, reason: 'timeout' }, planContent: '', n: 0 });
|
||
expect(d.block).toBe(true);
|
||
expect(d.degraded).toBe(true);
|
||
expect(d.message).toMatch(/не смог дозвониться|недоступен/i);
|
||
expect(d.recordMentorGo).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('decideMentorObjection — decision (мерж/Р7)', () => {
|
||
const noGo = { ran: true, wired: true, ok: true, verdict: { decision: 'NO-GO', recommendation: 'добавь systematic-debugging' } };
|
||
const go = { ran: true, wired: true, ok: true, verdict: { decision: 'GO' } };
|
||
it('содержательный NO-GO (ok=true, decision=NO-GO) → block + recordMentorGo:false', () => {
|
||
const d = decideMentorObjection({ res: noGo, planContent: '# п', n: 1 });
|
||
expect(d.block).toBe(true);
|
||
expect(d.recordMentorGo).toBe(false);
|
||
});
|
||
it('GO (decision=GO) → block:false + recordMentorGo:true', () => {
|
||
const d = decideMentorObjection({ res: go, planContent: '', n: 0 });
|
||
expect(d.block).toBe(false);
|
||
expect(d.recordMentorGo).toBe(true);
|
||
});
|
||
it('сломанный вердикт (wired && !ok) → block (как раньше)', () => {
|
||
expect(decideMentorObjection({ res: { ran: true, wired: true, ok: false }, planContent: '', n: 0 }).block).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('buildLlmCall (адаптер транспорта, паттерн судьи :167-177)', () => {
|
||
it('зовёт transport с {system,user} от buildPrompt и {apiKey, model}', async () => {
|
||
let got = null;
|
||
const transport = async (prompt, opts) => { got = { prompt, opts }; return '{"x":1}'; };
|
||
const call = buildLlmCall({ apiKey: 'K', model: 'M', transport });
|
||
const out = await call({ buildPrompt: () => ({ system: 'S', user: 'U' }) });
|
||
expect(out).toBe('{"x":1}');
|
||
expect(got.prompt).toEqual({ system: 'S', user: 'U' });
|
||
expect(got.opts).toEqual({ apiKey: 'K', model: 'M', perAttemptTimeoutMs: 300_000 });
|
||
});
|
||
// «Оба строго» (2026-06-12): адаптер apiKey НЕ подхватывает из env — что передали,
|
||
// то и ушло (строгость выбора ключа живёт у вызывающего: main → resolveMentorLlmKey).
|
||
it('apiKey undefined → transport получает undefined (адаптер env не читает)', async () => {
|
||
let got = null;
|
||
const transport = async (_p, opts) => { got = opts; return 'x'; };
|
||
await buildLlmCall({ apiKey: undefined, model: 'M', transport })({ buildPrompt: () => ({}) });
|
||
expect(got.apiKey).toBe(undefined);
|
||
});
|
||
});
|
||
|
||
// T6 «зубы» (решение владельца 2026-06-12): импорт внизу перед describe (ESM hoisting)
|
||
import { sealOnWiredGo } from './enforce-judge-gate.mjs';
|
||
|
||
describe('T6: mentorGate в sealOnWiredGo (freeze-gate перед печатью плана)', () => {
|
||
const planFp = 'docs/superpowers/plans/2026-06-12-t.md';
|
||
const goVerdict = { wired: true, decision: 'GO' };
|
||
const sealDeps = (persisted) => ({
|
||
key: 'k',
|
||
sealPlan: () => ({ sealed: true, seal: { plan_id: 'P' } }),
|
||
loadCurrentArtifact: () => ({ artifact_id: 'A' }),
|
||
persistPlan: (s) => { persisted.plan = s; },
|
||
});
|
||
it('mentorGate pass:false → sealed:false, persistPlan НЕ зовётся', () => {
|
||
const persisted = {};
|
||
const r = sealOnWiredGo({ event: { tool_input: { file_path: planFp, content: 'x' } }, verdict: goVerdict, judgeMode: 'shadow',
|
||
deps: { ...sealDeps(persisted), mentorGate: () => ({ pass: false, reason: 'нет вердикта наставника' }) } });
|
||
expect(r.sealed).toBe(false);
|
||
expect(r.reason).toMatch(/наставник|mentor/i);
|
||
expect(persisted.plan).toBe(undefined);
|
||
});
|
||
it('mentorGate pass:true → печать как раньше', () => {
|
||
const persisted = {};
|
||
const r = sealOnWiredGo({ event: { tool_input: { file_path: planFp, content: 'x' } }, verdict: goVerdict, judgeMode: 'shadow',
|
||
deps: { ...sealDeps(persisted), mentorGate: () => ({ pass: true, reason: 'ok' }) } });
|
||
expect(r.sealed).toBe(true);
|
||
expect(persisted.plan).toEqual({ plan_id: 'P' });
|
||
});
|
||
it('mentorGate undefined (наставник выключен) → старое поведение (характеризация)', () => {
|
||
const persisted = {};
|
||
const r = sealOnWiredGo({ event: { tool_input: { file_path: planFp, content: 'x' } }, verdict: goVerdict, judgeMode: 'shadow',
|
||
deps: sealDeps(persisted) });
|
||
expect(r.sealed).toBe(true);
|
||
});
|
||
it('mentorGate бросил → fail-CLOSE (sealed:false), печать не проходит', () => {
|
||
const persisted = {};
|
||
const r = sealOnWiredGo({ event: { tool_input: { file_path: planFp, content: 'x' } }, verdict: goVerdict, judgeMode: 'shadow',
|
||
deps: { ...sealDeps(persisted), mentorGate: () => { throw new Error('boom'); } } });
|
||
expect(r.sealed).toBe(false);
|
||
expect(persisted.plan).toBe(undefined);
|
||
});
|
||
});
|