Files
brain/tools/mentor-verdict.test.mjs
T
Дмитрий bc1d2a370a feat: B+C часть 2 — сеанс осмотра op:"session" под стеной
Новый тип шага плана op:"session" {goal, tools, produces} для интерактивного
осмотра (логин/формы/чужой сайт) под планом: внутри сеанса смотреть/кликать по
живым ref свободно, указатель не двигается; сеанс закрывает запись последнего
produces (матч-якорь). Снят дедлок op:"Skill"-как-шаг.

- plan-lock: sessionProduces, actionMatchesStep матчит последний produces,
  validatePlanTree валидирует session (produces>=1) и запрещает op:"Skill",
  sanitizeSessionTools (предохранитель §3.3: дроп Write/Edit/Bash/floor + warn).
- enforce-supreme-gate decide: ветка указатель-на-сеансе — tools сеанса и
  промежуточные produces allow без сдвига, пол применяется (defense-in-depth).
- plan-steps-parse: распознаёт op:"session" (goal/tools/produces, без object/ref),
  отвергает op:"Skill" с явным сообщением.
- mentor-verdict: наставник понимает op:"session" — не заворачивает как непонятный шаг.
- сеанс+tools/produces в хеше и подписи плана (подмена ломает печать).

Спека: docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md §3.2-3.3.
+37 тестов, свод 4266 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:54:42 +03:00

223 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/mentor-verdict.test.mjs
import { describe, it, expect } from 'vitest';
import { validateMentorVerdict, isMentorVerdictSubstantive, MENTOR_VERDICT_SLOTS, runMentorSpecVerdict, buildMentorSpecVerdictPrompt } from './mentor-verdict.mjs';
describe('runMentorSpecVerdict (Фаза 3 — наставник судит СПЕКУ, видит контекст Р6)', () => {
const GOOD = { plan_points_addressed: ['раздел §2 ок'], reasoning: 'разбор', recommendation: 'править §3', confidence: 0.9, decision: 'GO' };
it('валидный вердикт + wired:true + binding к хешу артефакта спеки', async () => {
const r = await runMentorSpecVerdict({ specContent: '# Спека\n## §2\nтекст', specHash: 'SH1', verifiedContext: [], negotiationLog: [], llmCall: async () => GOOD });
expect(r.ok).toBe(true);
expect(r.wired).toBe(true);
expect(r.verdict.plan_hash).toBe('SH1');
});
it('Р6: промпт несёт КОНТЕКСТ наставнику (verified-context), но просит разбор СПЕКИ', async () => {
let captured = null;
await runMentorSpecVerdict({
specContent: '# Спека', specHash: 'SH',
verifiedContext: [{ id: '1', kind: 'EXTRACTED', claim: 'утв', ref: 'x:1', anchor: 'якорь' }],
negotiationLog: [], llmCall: async ({ buildPrompt }) => { captured = buildPrompt(); return GOOD; },
});
expect(captured.system).toMatch(/спек/i);
expect(JSON.stringify(captured)).toMatch(/EXTRACTED|контекст/i);
});
it('сбой транспорта → wired:false (не суд, SE-R6-6)', async () => {
const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => { throw new Error('сеть'); } });
expect(r.wired).toBe(false);
});
it('несодержательный вердикт → ok:false, wired:true', async () => {
const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => ({ reasoning: 'r' }) });
expect(r.ok).toBe(false);
expect(r.wired).toBe(true);
});
});
describe('mentor-verdict (§6.1 СВОИ слоты)', () => {
it('MENTOR_VERDICT_SLOTS заморожен и СВОЙ (не судейский)', () => {
expect(Object.isFrozen(MENTOR_VERDICT_SLOTS)).toBe(true);
expect(MENTOR_VERDICT_SLOTS).toEqual(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence', 'decision']);
});
it('валидный вердикт → ok', () => {
const v = { plan_points_addressed: ['шаг 1 ок', 'шаг 2 риск'], reasoning: 'разбор', recommendation: 'править шаг 2', confidence: 0.8, decision: 'GO' };
expect(validateMentorVerdict(v).ok).toBe(true);
});
it('пустой слот → не ok', () => {
const v = { plan_points_addressed: [], reasoning: 'x', recommendation: 'y', confidence: 0.5 };
expect(validateMentorVerdict(v).ok).toBe(false);
expect(validateMentorVerdict(v).missingSlots).toContain('plan_points_addressed');
});
it('F-C3 (sharp-edges): элементы plan_points_addressed обязаны быть непустыми строками — [""]/[null]/[{}] не «содержательны»', () => {
const base = { reasoning: 'r', recommendation: 'rec', confidence: 0.5 };
expect(validateMentorVerdict({ ...base, plan_points_addressed: [''] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [null] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [{}] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: ['шаг 1 ок', ''] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [''] }).missingSlots).toContain('plan_points_addressed');
});
it('confidence вне [0,1]/NaN → не ok', () => {
const base = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec' };
expect(validateMentorVerdict({ ...base, confidence: 5 }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, confidence: NaN }).ok).toBe(false);
});
it('substance: wired:true + валиден → содержателен; wired:false → НЕ содержателен (SE-R6-6)', () => {
const v = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.7, decision: 'GO' };
expect(isMentorVerdictSubstantive(v, { wired: true })).toBe(true);
expect(isMentorVerdictSubstantive(v, { wired: false })).toBe(false);
});
});
describe('validateMentorVerdict — decision (Р7/мерж)', () => {
const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9 };
it('decision="GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true); });
it('decision="NO-GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'NO-GO' }).ok).toBe(true); });
it('decision отсутствует → невалиден', () => {
const r = validateMentorVerdict(base);
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('decision');
});
it('decision мусор → невалиден', () => {
expect(validateMentorVerdict({ ...base, decision: 'maybe' }).ok).toBe(false);
});
});
describe('флап-фикс: recommendation обязателен только на NO-GO (положительный GO не заворачивать)', () => {
const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', confidence: 0.9 };
it('GO + пустой recommendation → ok (на положительном разборе чинить нечего)', () => {
expect(validateMentorVerdict({ ...base, recommendation: '', decision: 'GO' }).ok).toBe(true);
});
it('GO + отсутствует recommendation → ok', () => {
expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true);
});
it('NO-GO + пустой recommendation → не ok, missingSlots несёт recommendation', () => {
const r = validateMentorVerdict({ ...base, recommendation: '', decision: 'NO-GO' });
expect(r.ok).toBe(false);
expect(r.missingSlots).toContain('recommendation');
});
it('NO-GO + непустой recommendation → ok (регресс не сломан)', () => {
expect(validateMentorVerdict({ ...base, recommendation: 'править шаг 2', decision: 'NO-GO' }).ok).toBe(true);
});
it('substance: GO + пустой recommendation + wired:true → содержателен', () => {
expect(isMentorVerdictSubstantive({ ...base, recommendation: '', decision: 'GO' }, { wired: true })).toBe(true);
});
});
describe('промпты просят decision (Р7/мерж)', () => {
it('промпт плана требует decision GO/NO-GO', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/decision/i);
expect(p.system).toMatch(/NO-GO/);
});
it('промпт спеки требует decision GO/NO-GO', () => {
const p = buildMentorSpecVerdictPrompt({ specContent: '# с' });
expect(p.system).toMatch(/decision/i);
expect(p.system).toMatch(/NO-GO/);
});
});
// Task 3b — производитель вердикта (C-1, нах.F4/F5): импорт внизу перед describe (ESM hoisting)
import { buildMentorVerdictPrompt, runMentorVerdict } from './mentor-verdict.mjs';
describe('buildMentorVerdictPrompt (§6.1 verdict-слоты, НЕ router)', () => {
it('возвращает {system,user}; user — план по-пунктам + проверенный контекст; system просит verdict-слоты', () => {
const p = buildMentorVerdictPrompt({
plan: { steps: [{ n: 1, op: 'Write', object: 'a.mjs' }] },
verifiedContext: [{ kind: 'EXTRACTED', claim: 'есть runRouter', ref: 'router-engine.mjs:140' }],
negotiationLog: [{ round: 1, side: 'mentor', utterance: 'расширь', justification: 'риск' }],
graphSection: { layer0: [{ district: 'tools', nodeCount: 3 }] },
});
expect(typeof p.system).toBe('string');
expect(typeof p.user).toBe('string');
expect(p.system).toMatch(/plan_points_addressed/); // СВОИ слоты, не router
expect(p.system).not.toMatch(/candidates|twins_considered/); // НЕ router-слоты
expect(p.user).toMatch(/runRouter/); // проверенный контекст
});
it('system несёт ДР-1 гранулярность (A8): растяжка до неизвестности + громкость по риску', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/растяжка до следующей НЕИЗВЕСТНОСТИ/);
expect(p.system).toMatch(/detectHighRisk/);
});
// VA-1 (финревью 3/5): рендер контекста/переговоров — ЕДИНЫЙ источник (mentor-seam);
// verdict-промпт несёт тот же дисклеймер «НЕ истина» и «обоснование:» что и seam.
it('VA-1: user несёт дисклеймер «НЕ истина» и «обоснование:» (единый рендер с seam)', () => {
const p = buildMentorVerdictPrompt({
plan: { steps: [] },
verifiedContext: [{ kind: 'EXTRACTED', claim: 'c', ref: 'f:1' }],
negotiationLog: [{ round: 1, side: 'mentor', utterance: 'u', justification: 'j' }],
});
expect(p.user).toMatch(/НЕ истина/);
expect(p.user).toMatch(/обоснование:/);
});
// VA-2 (финревью 3/5, «сигнал-всегда»): пустой verifiedContext НЕ молчит.
it('VA-2: пустой verifiedContext → явный маркер КОНТЕКСТ ПУСТ в user', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] }, verifiedContext: [] });
expect(p.user).toMatch(/КОНТЕКСТ ПУСТ/);
});
// Smoke 2026-06-12: «статус+замечание» провоцировал массив ОБЪЕКТОВ → валидатор
// (массив строк, F-C3/М1-М4) браковал живой содержательный вердикт. Контракт элементов
// обязан быть в промпте явно: строки, не объекты.
it('system требует plan_points_addressed массивом СТРОК (не объектов) — контракт валидатора F-C3', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/массив СТРОК/);
expect(p.system).toMatch(/не объект/i);
});
// B+C ч.2 (точка 5): наставник обязан понимать тип шага op:'session' (сеанс осмотра) —
// иначе завернёт его как «непонятный шаг». system объясняет: смотреть/кликать по живым ref,
// якорь закрытия = produces.
it('system объясняет op:session (сеанс осмотра: goal/tools/produces) — не «непонятный шаг»', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [{ n: 1, op: 'session', goal: 'осмотр', tools: ['browser_click'], produces: 'r.md' }] } });
expect(p.system).toMatch(/session|сеанс/i);
expect(p.system).toMatch(/produces/i);
});
it('roundMemory: пусто → нет блока; непусто → блок памяти (оба построителя)', () => {
expect(buildMentorVerdictPrompt({ plan: { steps: [] } }).user).not.toContain('ПАМЯТЬ КРУГОВ');
expect(buildMentorVerdictPrompt({ plan: { steps: [] }, roundMemory: { objections: ['замечание M'] } }).user).toContain('замечание M');
expect(buildMentorSpecVerdictPrompt({ specContent: '# с', roundMemory: { objections: ['замечание M2'] } }).user).toContain('замечание M2');
});
});
describe('runMentorVerdict (§6.1 производитель + binding нах.F4)', () => {
const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, decision: 'GO' };
it('валидный вердикт → ok + wired + verdict.plan_hash проставлен (binding)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => goodVerdict });
expect(r.ok).toBe(true);
expect(r.wired).toBe(true);
expect(r.verdict.plan_hash).toBe('PH1');
});
it('пустой слот → ok:false (не содержателен)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => ({ ...goodVerdict, plan_points_addressed: [] }) });
expect(r.ok).toBe(false);
});
it('сбой вызова → ok:false, wired:false (SE-R6-6: не суд)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('сеть'); } });
expect(r.ok).toBe(false);
expect(r.wired).toBe(false);
});
it('сбой вызова → reason несёт деталь ошибки (smoke 2026-06-12: тихий catch не давал отличить 401 от сети)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('Router LLM 401: invalid api key'); } });
expect(r.reason).toMatch(/сбой вызова наставника-вердикта/);
expect(r.reason).toMatch(/401/);
});
it('сбой вызова: деталь усечена (не тащим мегабайт тела ответа в вердикт-файл)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('x'.repeat(5000)); } });
expect(r.reason.length).toBeLessThan(400);
});
it('строковый ответ модели парсится (json-fence), вердикт валиден → ok', async () => {
const json = JSON.stringify(goodVerdict);
const r = await runMentorVerdict({ plan: {}, planHash: 'PH2', llmCall: async () => '```json\n' + json + '\n```' });
expect(r.ok).toBe(true);
expect(r.verdict.plan_hash).toBe('PH2');
});
});
describe('производители прокидывают roundMemory в построитель (SP2c-2)', () => {
const GOODV = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, decision: 'GO' };
it('runMentorVerdict → roundMemory доходит до промпта', async () => {
let captured = null;
await runMentorVerdict({ plan: {}, planHash: 'PH', roundMemory: { objections: ['замечание прошлого круга M'] }, llmCall: async ({ buildPrompt }) => { captured = buildPrompt(); return GOODV; } });
expect(captured.user).toContain('замечание прошлого круга M');
});
it('runMentorSpecVerdict → roundMemory доходит до промпта', async () => {
let captured = null;
await runMentorSpecVerdict({ specContent: '# с', specHash: 'SH', roundMemory: { objections: ['замечание прошлого круга спеки'] }, llmCall: async ({ buildPrompt }) => { captured = buildPrompt(); return GOODV; } });
expect(captured.user).toContain('замечание прошлого круга спеки');
});
});