Files
brain/tools/mentor-verdict.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

145 lines
13 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.
#!/usr/bin/env node
/**
* mentor-verdict (§6.1) — вердикт наставника со СВОИМИ слотами (НЕ судейские
* validateVerdictSlots/линзы — SE-R7-8). Паттерн-зеркало validateTrace, но собственный
* набор. substance (SE-R6-6): содержателен = валиден ПО слотам И wired:true (реальный
* вызов, не stub/degraded). Зеркало: судья {decision:'GO', wired:false} не суд.
*/
/** СОБСТВЕННЫЕ слоты наставника (адресуют план по-пунктам). decision (Р7/мерж) — явная
* кнопка GO/NO-GO: содержательное «переделай» обязано заворачивать, не тонуть как GO. */
export const MENTOR_VERDICT_SLOTS = Object.freeze(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence', 'decision']);
/** Валидация вердикта по СВОИМ слотам. plan_points_addressed — непустой массив;
* reasoning/recommendation — непустые строки; confidence — конечное число ∈[0,1]
* (зеркало F-8: NaN/Infinity/вне диапазона — не «уверен»). */
export function validateMentorVerdict(verdict) {
const missingSlots = [];
if (!verdict || typeof verdict !== 'object') return { ok: false, missingSlots: [...MENTOR_VERDICT_SLOTS] };
// F-C3 (sharp-edges): пункты обязаны быть НЕПУСТЫМИ СТРОКАМИ — [''] / [null] / [{}]
// формально «непустой массив», но субстанции нет (R2-VA-meta presence ≠ substance).
if (!Array.isArray(verdict.plan_points_addressed) || verdict.plan_points_addressed.length === 0
|| !verdict.plan_points_addressed.every((p) => typeof p === 'string' && p.trim())) missingSlots.push('plan_points_addressed');
// reasoning — обязателен ВСЕГДА (разбор по существу нужен и на GO, и на NO-GO).
if (typeof verdict.reasoning !== 'string' || !verdict.reasoning.trim()) missingSlots.push('reasoning');
// recommendation = «что править» — обязателен ТОЛЬКО на NO-GO; при decision='GO' (положительный
// разбор) чинить нечего → слот пуст по смыслу, валидатор НЕ заворачивает (фикс ложного флапа
// «несодержательный вердикт: пустые слоты [recommendation]» при содержательном GO).
if (verdict.decision === 'NO-GO' && (typeof verdict.recommendation !== 'string' || !verdict.recommendation.trim())) missingSlots.push('recommendation');
if (typeof verdict.confidence !== 'number' || !Number.isFinite(verdict.confidence) || verdict.confidence < 0 || verdict.confidence > 1) missingSlots.push('confidence');
// decision (Р7/мерж): явная кнопка GO/NO-GO — только {GO, NO-GO}; иначе вердикт несодержателен.
if (verdict.decision !== 'GO' && verdict.decision !== 'NO-GO') missingSlots.push('decision');
return { ok: missingSlots.length === 0, missingSlots };
}
/** Содержателен ли вердикт: валиден ПО слотам И из РЕАЛЬНОГО захода (wired:true). */
export function isMentorVerdictSubstantive(verdict, { wired = false } = {}) {
return wired === true && validateMentorVerdict(verdict).ok;
}
// Task 3b — производитель вердикта (C-1): импорт здесь валиден (ESM hoisting).
// VA-1 (финревью 3/5): рендеры контекста/переговоров + ДР-1 строка — ЕДИНЫЙ источник
// mentor-seam (дубль-литералы с дрейфом сняты; циклов нет — seam verdict не импортирует).
import { parseRouterResponse } from './router-engine.mjs';
import { DR1_LINE, renderVerifiedContext, renderNegotiation } from './mentor-seam.mjs';
import { renderRoundMemory } from './round-memory-render.mjs';
/**
* Промпт-производитель вердикта (§6.1): зеркало идиомы buildRouterPrompt {system,user},
* но system просит ИМЕННО verdict-слоты (plan_points_addressed по-пунктам / reasoning /
* recommendation / confidence), НЕ router-слоты. user — план по-пунктам + проверенный
* контекст + журнал переговоров (единые рендеры seam, VA-1; пустой контекст НЕ молчит,
* VA-2) + граф-секция (опора наставника). A8 (нах.F6): system несёт ДР-1 гранулярность.
*/
export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, skillContext = null, roundMemory = {} } = {}) {
const system = [
'Ты — НАСТАВНИК. Разбери ПЛАН ПО ПУНКТАМ (не выбирай скил — это другой вызов).',
DR1_LINE,
// B+C ч.2 (точка 5): шаг op:"session" — ЛЕГИТИМНЫЙ тип (сеанс осмотра). Не заворачивай его
// как «непонятный шаг»: интерактив (логин/формы/чужой сайт) нельзя расписать по кликам заранее.
'Шаг op:"session" — это СЕАНС ОСМОТРА (легитимный тип шага): {goal — что осмотреть, tools — действующие инструменты сеанса (клик/ввод/MCP), produces — итоговый файл(ы), ≥1}. Внутри сеанса агент смотрит и кликает по живым ref сколько нужно; закрывает сеанс запись последнего produces. Суди ЦЕЛЬ и итоговый файл, не требуй расписать клики и не требуй для сеанса отдельный шаг-verify (его роль играет produces-отчёт).',
'Переоцени ТЕКУЩУЮ версию заново по памяти кругов ниже; что уже снято — НЕ повторяй.',
// Smoke 2026-06-12: «статус+замечание» без типа элемента провоцировал массив объектов —
// валидатор (F-C3/М1-М4: слот = строки) браковал содержательный вердикт. Тип — явно.
'Вынеси РЕШЕНИЕ: decision="GO" если можно реализовывать, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).',
'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ пункту плана ровно одна строка вида «пункт N: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1), decision ("GO"|"NO-GO"). Пустой слот недопустим.',
].join('\n');
const user = [
`ПЛАН: ${plan ? JSON.stringify(plan) : '(нет)'}`,
renderVerifiedContext(verifiedContext),
renderNegotiation(negotiationLog),
renderRoundMemory(roundMemory),
graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '',
skillContext ? skillContext : '',
].filter(Boolean).join('\n');
return { system, user };
}
/**
* Производитель вердикта (§6.1, зеркало runRouter [router-engine.mjs:140]): промпт →
* llmCall (инъект; живой транспорт — активация владельца) → parse → validateMentorVerdict →
* при валидном wired:true + verdict с plan_hash (binding нах.F4: freeze-gate сверит
* verdict.plan_hash === planId(steps) — stale/чужой вердикт не пройдёт). Сбой → wired:false
* (SE-R6-6: не суд). roundMemory (SP2c-2) — память кругов M-side, прокидывается в построитель.
*/
export async function runMentorVerdict({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, planHash = null, skillContext = null, roundMemory = {}, llmCall }) {
let v;
try {
v = await llmCall({ buildPrompt: () => buildMentorVerdictPrompt({ plan, verifiedContext, negotiationLog, graphSection, skillContext, roundMemory }) });
} catch (e) {
// Smoke 2026-06-12: тихий catch не давал отличить 401 (ключ) от сети — деталь
// обязана доехать до вердикт-файла/журнала (усечённая; ключ в message не попадает).
const detail = String((e && e.message) || e).slice(0, 200);
return { ok: false, wired: false, reason: `сбой вызова наставника-вердикта: ${detail}`, verdict: null };
}
if (typeof v === 'string') v = parseRouterResponse(v);
if (!v) return { ok: false, wired: false, reason: 'пустой/неразборный вердикт', verdict: null };
const chk = validateMentorVerdict(v);
if (!chk.ok) return { ok: false, wired: true, reason: `несодержательный вердикт: пустые слоты [${chk.missingSlots.join(', ')}]`, verdict: v };
return { ok: true, wired: true, verdict: { ...v, plan_hash: planHash } };
}
/**
* Фаза 3 (отдельный spec-путь, Р6): промпт-производитель вердикта наставника по СПЕКЕ.
* Зеркало buildMentorVerdictPrompt, но просит разбор СПЕКИ по разделам (не плана по пунктам).
* Наставник ВИДИТ контекст (verified-context + переговоры — Р6), судья — нет. Те же слоты.
*/
export function buildMentorSpecVerdictPrompt({ specContent = '', verifiedContext = [], negotiationLog = [], graphSection = null, roundMemory = {} } = {}) {
const system = [
'Ты — НАСТАВНИК. Разбери СПЕКУ ПО РАЗДЕЛАМ (это спецификация решения, не план; скил не выбираешь).',
DR1_LINE,
'Переоцени ТЕКУЩУЮ версию заново по памяти кругов ниже; что уже снято — НЕ повторяй.',
'Вынеси РЕШЕНИЕ: decision="GO" если спеку можно принять, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).',
'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ разделу/решению спеки ровно одна строка вида «раздел X: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1), decision ("GO"|"NO-GO"). Пустой слот недопустим.',
].join('\n');
const user = [
`СПЕКА:\n${specContent || '(нет)'}`,
renderVerifiedContext(verifiedContext),
renderNegotiation(negotiationLog),
renderRoundMemory(roundMemory),
graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '',
].filter(Boolean).join('\n');
return { system, user };
}
/**
* Фаза 3 (Р6): производитель вердикта наставника по СПЕКЕ. Зеркало runMentorVerdict, но
* spec-промпт; binding plan_hash = specHash (хеш артефакта спеки — judgedHashOf(sealableArtifact),
* тот же, чем судья печатает gate1). Сбой → wired:false (SE-R6-6, не суд). roundMemory (SP2c-2)
* — память кругов M-side спеки, прокидывается в построитель.
*/
export async function runMentorSpecVerdict({ specContent = '', specHash = null, verifiedContext = [], negotiationLog = [], graphSection = null, roundMemory = {}, llmCall }) {
let v;
try {
v = await llmCall({ buildPrompt: () => buildMentorSpecVerdictPrompt({ specContent, verifiedContext, negotiationLog, graphSection, roundMemory }) });
} catch (e) {
const detail = String((e && e.message) || e).slice(0, 200);
return { ok: false, wired: false, reason: `сбой вызова наставника-вердикта (спека): ${detail}`, verdict: null };
}
if (typeof v === 'string') v = parseRouterResponse(v);
if (!v) return { ok: false, wired: false, reason: 'пустой/неразборный вердикт', verdict: null };
const chk = validateMentorVerdict(v);
if (!chk.ok) return { ok: false, wired: true, reason: `несодержательный вердикт: пустые слоты [${chk.missingSlots.join(', ')}]`, verdict: v };
return { ok: true, wired: true, verdict: { ...v, plan_hash: specHash } };
}