bc1d2a370a
Новый тип шага плана 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>
223 lines
15 KiB
JavaScript
223 lines
15 KiB
JavaScript
// 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('замечание прошлого круга спеки');
|
||
});
|
||
});
|