Files
brain/tools/enforce-mentor-on-plan-write.test.mjs
T

254 lines
15 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-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');
});
});
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);
});
});