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>
145 lines
13 KiB
JavaScript
145 lines
13 KiB
JavaScript
#!/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 } };
|
||
}
|