#!/usr/bin/env node /** * judge-engine (§7/§8/§10, K7, G1) — думающая часть судьи. Образец — router-classifier: * построитель промпта ЧИСТЫЙ (детерминированно тестируется), сам вызов модели МОКАЕТСЯ, * а ПРОВЕРКИ вокруг — тупо-механические (пол судьи 4-A). «Умный не баран». * - VOTE_LAYOUTS: раскладка голосов по функциям (§7), голоса РАЗДЕЛЬНО. * - PREMORTEM_CLASSES: фиксированные классы провала (§10) — общая карта, не растущий список. * - K7: премортем-слот на Гейте-1 обязателен (входит в layout gate1). * - G1 classifyByReversibility: фатальное = тяжёлое И необратимое → блок; иначе совет. * - runJudge: один вердикт + механический пост-чек (слоты/под-прогоны/якорь/обратимость). * - consensusDecision (J3): на высоком риске несколько судей, любой НЕТ → блок. * Судья СЛЕП к переписке: на вход подаётся только продукт+цель+карточки (по конструкции). */ import { canonicalJson } from './receipt-sign.mjs'; import { validateVerdictSlots } from './judge-verdict-slots.mjs'; import { partitionObjections } from './judge-anchor.mjs'; import { renderRoundMemory } from './round-memory-render.mjs'; import { requiredSubRunsPresent } from './judge-subrun-journal.mjs'; export const VOTE_LAYOUTS = Object.freeze({ gate1: ['completeness', 'premortem', 'goal_advocate', 'correctness'], // премортем = K7 gate2: ['spec_fidelity', 'verifiability', 'plan_soundness', 'execution_risk', 'step_clarity', 'delivery_honesty'], a2_divergence: ['intent_fidelity'], a2_destructive: ['radius_reversibility', 'attacker'], part_light: ['correctness', 'simplicity', 'footgun'], part_risky: ['radius_tests', 'twins', 'attacker'], gate3: ['goal_achieved', 'premortem_whole', 'behavior_vs_goal'], gate3card: ['card_matches_product', 'no_overstatement', 'verify_steps_real'], }); export const PREMORTEM_CLASSES = Object.freeze([ 'technique', 'seam', 'user', 'future_change', 'abuse', 'money_irreversible', ]); /** Линзы функции (+ по риску добавляются attacker и money). */ export function requiredLensesFor(functionName, { risk = false } = {}) { const base = VOTE_LAYOUTS[functionName] ? [...VOTE_LAYOUTS[functionName]] : []; if (risk) { if (!base.includes('attacker')) base.push('attacker'); if (!base.includes('money')) base.push('money'); } return base; } /** Чистый построитель промпта судьи: {system, user}. Детерминирован. */ export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], roundMemory = {}, delivery = null }) { const systemLines = [ 'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.', 'Ты СЛЕП к любой внешней переписке и чужим мнениям — судишь продукт против цели; учитываешь лишь СВОИ прошлые замечания и доводы контроллера лично тебе.', 'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).', `Функция: ${functionName}.`, `Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`, `Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`, 'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.', ]; if (requiredLenses.includes('delivery_honesty')) { systemLines.push('Линза delivery_honesty (честность пометки поставки): план несёт пометку DELIVERY (internal|user-result). Если план доводит ЦЕЛЬ спеки до готового ПОЛЬЗОВАТЕЛЬСКОГО результата (а не только внутренней машинерии), а помечен internal — это СОКРЫТИЕ готового результата от владельца: вынеси возражение verdict:NO, severity:heavy, reversible:false, anchor {kind:"failed_criterion", ref:"delivery=internal при пользовательском результате"}. Честная пометка возражения не требует.'); } const system = systemLines.join('\n'); const user = [ `ЦЕЛЬ (неизменный якорь): ${goal}`, `КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`, delivery ? `ПОМЕТКА DELIVERY: ${delivery}` : null, `ПРОДУКТ НА СУД: ${canonicalJson(product)}`, renderRoundMemory(roundMemory), ].filter(Boolean).join('\n'); return { system, user }; } /** * G1: что блокирует (фатальное=тяжёлое И необратимое), что идёт советом. * #8 (сомнение→блок): тяжёлое блокирует, ПОКА не доказано обратимое (reversible===true). * Отсутствующая/неизвестная обратимость у тяжёлого возражения → блок (fail-closed). */ export function classifyByReversibility({ severity, reversible } = {}) { const heavy = severity === 'fatal' || severity === 'heavy'; return (heavy && reversible !== true) ? 'block' : 'advice'; } function isBlockingObjection(o) { return classifyByReversibility(o) === 'block'; } /** * Один заход судьи + механический пост-чек. llmCall(prompt)→{decision, slots, objections}. * Механика поверх модели: слоты валидны? под-прогоны на месте? какие возражения реально * блокируют (якорь + необратимость)? Модель НЕ может проштамповать GO мимо механики. */ export function runJudge({ functionName, requiredLenses, subRunsRequired = [], subRuns = [], llmCall, promptArgs = {} }) { const prompt = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs }); const raw = llmCall(prompt) || {}; const slotCheck = validateVerdictSlots(raw, requiredLenses); if (!slotCheck.valid) { return { decision: 'NO-GO', accepted: false, reason: 'слоты вердикта невалидны (§9.2)', slotCheck }; } const subRunCheck = requiredSubRunsPresent(subRunsRequired, subRuns); if (!subRunCheck.ok) { return { decision: 'NO-GO', accepted: false, reason: 'нет требуемого под-прогона (фейк прилежности, дисциплина #1)', subRunCheck }; } // Аудит M1-M4 (свежий объектив): нормализуем выход модели — objections может прийти // не массивом (строка/объект) или с null-элементом. Раньше `(raw.objections||[])` гасил // только falsy → .filter падал на не-массиве, а `o.verdict` — на null-элементе (краш ломал // вердикт; в инертной обёртке выброс уходил в catch→block:false = fail-open). Теперь не падает. const rawObjs = Array.isArray(raw.objections) ? raw.objections : []; const noObjs = rawObjs.filter((o) => o && o.verdict === 'NO'); const { blocking: anchored, advisory: anchorless } = partitionObjections(noObjs); const trulyBlocking = anchored.filter(isBlockingObjection); // якорь + необратимость const demotedReversible = anchored.filter((o) => !isBlockingObjection(o)); // якорь, но обратимо → совет const advice = [...anchorless, ...demotedReversible]; return { decision: trulyBlocking.length ? 'NO-GO' : 'GO', accepted: true, blocking: trulyBlocking, advice, slots: raw.slots, }; } /** * J3-консенсус (высокий риск, несколько судей): GO только если ЕСТЬ голоса И КАЖДЫЙ * из них чистый GO. Иначе NO-GO. (Аудит M1-M4: пустой список и битый/неполный голос * раньше дрейфовали к GO — «нет согласия = не блок» неверно для hard-risk; теперь fail-closed.) */ export function consensusDecision(verdicts = []) { const list = verdicts || []; if (list.length === 0) return 'NO-GO'; return list.every((v) => v && v.decision === 'GO') ? 'GO' : 'NO-GO'; }