2026-06-15 08:06:08 +03:00
#!/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' ;
2026-06-16 15:59:39 +03:00
import { renderRoundMemory } from './round-memory-render.mjs' ;
2026-06-15 08:06:08 +03:00
import { requiredSubRunsPresent } from './judge-subrun-journal.mjs' ;
export const VOTE _LAYOUTS = Object . freeze ( {
gate1 : [ 'completeness' , 'premortem' , 'goal_advocate' , 'correctness' ] , // премортем = K7
2026-06-17 18:01:24 +03:00
gate2 : [ 'spec_fidelity' , 'verifiability' , 'plan_soundness' , 'execution_risk' , 'step_clarity' , 'delivery_honesty' ] ,
2026-06-15 08:06:08 +03:00
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' ] ,
2026-06-17 16:44:55 +03:00
gate3card : [ 'card_matches_product' , 'no_overstatement' , 'verify_steps_real' ] ,
2026-06-15 08:06:08 +03:00
} ) ;
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}. Детерминирован. */
2026-06-17 18:01:24 +03:00
export function buildJudgePrompt ( { functionName , requiredLenses = [ ] , product = { } , goal = '' , cards = [ ] , roundMemory = { } , delivery = null } ) {
const systemLines = [
2026-06-15 08:06:08 +03:00
'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.' ,
2026-06-17 03:33:42 +03:00
'Ты СЛЕП к любой внешней переписке и чужим мнениям — судишь продукт против цели; учитываешь лишь СВОИ прошлые замечания и доводы контроллера лично тебе.' ,
2026-06-15 08:06:08 +03:00
'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).' ,
` Функция: ${ functionName } . ` ,
` Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${ requiredLenses . join ( ', ' ) } . ` ,
` Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${ PREMORTEM _CLASSES . join ( ', ' ) } . ` ,
'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.' ,
2026-06-17 18:01:24 +03:00
] ;
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' ) ;
2026-06-15 08:06:08 +03:00
const user = [
` ЦЕЛЬ (неизменный якорь): ${ goal } ` ,
` КАРТОЧКИ НАВЫКОВ: ${ canonicalJson ( cards ) } ` ,
2026-06-17 18:01:24 +03:00
delivery ? ` ПОМЕТКА DELIVERY: ${ delivery } ` : null ,
2026-06-15 08:06:08 +03:00
` ПРОДУКТ НА СУД: ${ canonicalJson ( product ) } ` ,
2026-06-16 15:59:39 +03:00
renderRoundMemory ( roundMemory ) ,
] . filter ( Boolean ) . join ( '\n' ) ;
2026-06-15 08:06:08 +03:00
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' ;
}