// 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); }); // SP2c-2: хук грузит память кругов M-side через roundMemoryImpl и протягивает её до // построителя вердикта наставника (план и спека). stage прокидывается верно. it('SP2c-2: roundMemoryImpl (план) → память доходит до промпта наставника', async () => { let capturedUser = null; const d = deps({ roundMemoryImpl: ({ stage }) => ({ objections: [`память дорожки ${stage}`] }), llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GOOD_VERDICT; }, }); const r = await runMentorOnPlanWrite(planEvent, d); expect(r.ran).toBe(true); expect(capturedUser).toContain('память дорожки plan'); }); it('SP2c-2: roundMemoryImpl (спека) → stage=spec в памяти промпта', async () => { let capturedUser = null; const d = deps({ roundMemoryImpl: ({ stage }) => ({ objections: [`память дорожки ${stage}`] }), llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GOOD_VERDICT; }, }); const r = await runMentorOnPlanWrite(specEvent, d); expect(r.ran).toBe(true); expect(capturedUser).toContain('память дорожки spec'); }); // Видимость: результат роутера протягивается наружу из runMentorOnPlanWrite (для снимка/баннера). it('видимость: runMentorOnPlanWrite (план) возвращает routerClassification', async () => { const d = deps({ classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }) }); const r = await runMentorOnPlanWrite(planEvent, d); expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] }); }); // Фикс silent-swallow (Уроки №7): срыв В РЕГИОНЕ наставника (между роутером и LLM-вердиктом — // renderSkillContext, загрузчик памяти кругов и пр.) раньше реджектил промис → молчаливый catch // main() → ни вердикта, ни снимка, ни печати. Теперь → видимый degraded (ran:true, wired:false). it('срыв в регионе наставника (план, roundMemoryImpl бросил) → ran:true, wired:false (видимый degraded, не реджект)', async () => { const d = deps({ roundMemoryImpl: () => { throw new Error('boom-в-регионе'); } }); const r = await runMentorOnPlanWrite(planEvent, d); expect(r.ran).toBe(true); expect(r.wired).toBe(false); expect(r.reason).toMatch(/сорвал/i); expect(typeof r.planHash).toBe('string'); expect(r.planHash.length).toBeGreaterThan(0); }); it('срыв в регионе наставника (спека, roundMemoryImpl бросил) → ran:true, wired:false', async () => { const d = deps({ roundMemoryImpl: () => { throw new Error('boom-спека'); } }); const r = await runMentorOnPlanWrite(specEvent, d); expect(r.ran).toBe(true); expect(r.wired).toBe(false); expect(r.reason).toMatch(/сорвал/i); }); }); 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); }); // SP2d: маркер `**Арбитраж:**` в плане → карточка арбитража на ЛЮБОМ круге (не ждём 3-го). it('SP2d: маркер арбитража в плане → карточка даже на круге 1', () => { const planWithMarker = '# план\n## Переговоры\n**Арбитраж:** тупик по шагу 2\n### Круг 1\n**Наставнику:** не согласен'; const d = decideMentorObjection({ res: noGo, planContent: planWithMarker, n: 1 }); expect(d.block).toBe(true); expect(d.message).toContain('Что меняет выбор'); // маркер карточки арбитража, не обычный фидбек }); }); 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); }); });