2026-06-04 19:43:57 +03:00
#!/usr/bin/env node
/**
* router-engine — думающая голова роутера (3-D). РАССУЖДЕНИЕ — у модели (llmCall
* инъектируется/мокается, идиома router-classifier); все ПРОВЕРКИ/ЗАЩИТЫ вокруг —
* тупо-механические и покрыты тестами («умный не баран»). Группы 1/2/5/6 + look-ahead
* + L-ядро + риск-фильтр W1+W2 + деньги 6.1/6.2. K4-поправку к стене и live-wiring
* НЕ трогает (см. журнал вопросов: только после Машины 4).
*/
import { resolveNode } from './node-graph.mjs' ;
2026-06-07 11:21:38 +03:00
import { classifyDestructive } from './classify-destructive.mjs' ;
// Разрушительные глаголы — единый источник classify-destructive.mjs (§4, Δ9-б ):
// локальный DESTRUCTIVE_RE удалён, риск-проверка берёт .suspicious из классификатора.
2026-06-04 19:43:57 +03:00
/**
* 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/
* инъецированному sensitive-флагу). LLM-оценка риска — ТОЛЬКО совет, НЕ триггер
* блока (жёсткий блок нельзя вешать на догадку модели). stepThreshold — ручка.
*/
export function detectHighRisk ( { op , command = '' , prodDeploy = false , stepCount = 0 , sensitive = false } = { } , { stepThreshold = 7 } = { } ) {
const reasons = [ ] ;
2026-06-07 11:21:38 +03:00
if ( classifyDestructive ( command ) . suspicious ) reasons . push ( 'разрушительная операция' ) ;
2026-06-04 19:43:57 +03:00
if ( prodDeploy ) reasons . push ( 'прод-выкат' ) ;
if ( stepCount >= stepThreshold ) reasons . push ( ` многошаговость ( ${ stepCount } ≥ ${ stepThreshold } ) ` ) ;
if ( sensitive ) reasons . push ( 'чувствительная зона (инъецированный флаг)' ) ;
return { high : reasons . length > 0 , reasons } ;
}
// 6.2 — закрытый список категорий пропуска уровня (свободный текст отклоняется).
export const LEVEL _SKIP _CATEGORIES = Object . freeze ( [ 'atomic' , 'trivial' , 'obvious-single-path' , 'no-skill-needed' ] ) ;
/** 6.2 — пропуск уровня только ЯВНОЙ категорией из закрытого списка. */
export function validateLevelSkip ( category ) {
const ok = typeof category === 'string' && LEVEL _SKIP _CATEGORIES . includes ( category . trim ( ) ) ;
return { ok , allowed : LEVEL _SKIP _CATEGORIES } ;
}
/**
* 6.1 — цена как РАЗДЕЛИТЕЛЬ равноценных: сначала лучшее качество (min qualityRank),
* среди равных по качеству — min costRank. Цена НЕ важнее правильности.
*/
export function cheaperOf ( candidates ) {
if ( ! Array . isArray ( candidates ) || candidates . length === 0 ) return null ;
return [ ... candidates ] . sort ( ( a , b ) =>
( a . qualityRank - b . qualityRank ) || ( a . costRank - b . costRank ) ) [ 0 ] ;
}
// 5.1 — обязательные структурные слоты трассы (схема=метод, §6.2 A).
export const TRACE _SLOTS = Object . freeze ( [ 'candidates' , 'chosen' , 'why_chosen' , 'twins_considered' , 'confidence' ] ) ;
/**
* Механический валидатор трассы (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B, аналог A0):
* пустой/отсутствующий слот = красный флаг. candidates — непустой массив,
* confidence — число, остальные — непустые строки.
*/
export function validateTrace ( trace ) {
const missingSlots = [ ] ;
if ( ! trace || typeof trace !== 'object' ) return { ok : false , missingSlots : [ ... TRACE _SLOTS ] } ;
if ( ! Array . isArray ( trace . candidates ) || trace . candidates . length === 0 ) missingSlots . push ( 'candidates' ) ;
for ( const slot of [ 'chosen' , 'why_chosen' , 'twins_considered' ] ) {
if ( typeof trace [ slot ] !== 'string' || ! trace [ slot ] . trim ( ) ) missingSlots . push ( slot ) ;
}
2026-06-07 06:24:21 +03:00
// F-8 (аудит M1-M4): confidence обязан быть конечным числом в [0,1]. Иначе
// (NaN/Infinity/5/-3) проходил `typeof === 'number'`, а порог `< threshold`
// трактовал 5 как «уверен» (обход воздержания 5.2) либо -3 как принуд. abstain.
if ( typeof trace . confidence !== 'number' || ! Number . isFinite ( trace . confidence ) || trace . confidence < 0 || trace . confidence > 1 ) missingSlots . push ( 'confidence' ) ;
2026-06-04 19:43:57 +03:00
return { ok : missingSlots . length === 0 , missingSlots } ;
}
/**
* ОВ-Д2 / §6.3 C — заземление: каждый кандидат И выбранный обязаны резолвиться в
* реальный узел графа (3-B resolveNode); выдумка → grounded=false + перечень invented.
*/
export function groundTrace ( trace , graph ) {
const invented = [ ] ;
const check = ( ref ) => { if ( resolveNode ( graph , ref ) === null ) invented . push ( ref ) ; } ;
for ( const cand of ( trace ? . candidates || [ ] ) ) check ( cand ) ;
if ( trace ? . chosen ) check ( trace . chosen ) ;
return { grounded : invented . length === 0 , invented } ;
}
2026-06-07 03:55:52 +03:00
/**
* F1 — выбор ОБЯЗАН быть среди перечисленных кандидатов (§6.3 метод-брейнсторм:
* сперва 2–3 кандидата, ПОТОМ выбор ИЗ них). Реальный, но не перечисленный chosen —
* обход метода («выбор с потолка»), который заземление пропускает. Чистая проверка.
*/
export function chosenIsCandidate ( trace ) {
return Array . isArray ( trace ? . candidates )
&& typeof trace ? . chosen === 'string' && trace . chosen . trim ( ) . length > 0
&& trace . candidates . includes ( trace . chosen ) ;
}
2026-06-04 19:43:57 +03:00
/**
* Построитель промпта (чистый, {system,user} — идиома buildClassifierPromptStructured).
* system: статичные инструкции + вшитый брейнсторм (2–3 варианта → спор → выбор) +
* L-ядро (L1/L3/L4/L5/L7) + схема слотов трассы 5.1 + ПОЛНЫЙ граф+каталог (6.2-исключение:
* в system для кэша). user: задача (+план/прогресс) — волатильное.
*/
2026-06-05 03:24:49 +03:00
export function buildRouterPrompt ( { prompt = '' , plan = null , graph = null , catalog = null , contracts = [ ] } = { } ) {
2026-06-11 19:09:39 +03:00
// W1 (C2, нах.F1/CD-R6-G): catalog ≠ graph. Каталог скилов — ТОЛЬКО из catalog.nodes
// (граф больше не фолбэк каталога); project-граф (graphSection B: {layer0, staleness})
// рендерится ОТДЕЛЬНОЙ секцией ниже.
const nodesForCtx = ( catalog && catalog . nodes ) || [ ] ;
// F-C2-3 (sharp-edges C2): пустой каталог НЕ молчит (зеркало F-C6 «сигнал всегда») —
// иначе «кандидаты обязаны быть из каталога» при пустом каталоге тихо гонит в выдумку.
const catalogText = nodesForCtx . length
? nodesForCtx . map ( ( n ) => ` ${ n . id || '' } ${ n . slug || '' } — ${ n . capabilities || n . name || '' } ` ) . join ( '\n' )
: 'КАТАЛОГ ПУСТ (catalog не передан/пуст) — кандидатов нет: не выдумывай узлы, ставь низкую уверенность (воздержание 5.2)' ;
2026-06-11 20:10:42 +03:00
// FR-2 (финревью 2026-06-11): пустой layer0 → null (не пустой заголовок секции);
// маркер ОТСУТСТВИЯ карты для mentor-пути — забота mentor-seam (F-C6).
const layer0 = ( graph && Array . isArray ( graph . layer0 ) && graph . layer0 . length ) ? graph . layer0 : null ;
2026-06-11 19:09:39 +03:00
const districtLines = layer0 ? layer0 . map ( ( d ) => {
const top = Array . isArray ( d . topNodes ) ? d . topNodes . map ( ( n ) => n && n . id ) . filter ( Boolean ) . join ( ', ' ) : '' ;
return ` ${ d . district } ( ${ d . nodeCount } узлов) ${ top ? ` — топ: ${ top } ` : '' } ` ;
} ) : null ;
const st = graph && graph . staleness ;
const stalenessLine = st ? ` staleness: stale= ${ ! ! st . stale } commits_behind= ${ st . commits _behind ? ? '?' } uncommitted= ${ st . uncommitted ? ? '?' } ` : '' ;
2026-06-05 03:24:49 +03:00
const cs = Array . isArray ( contracts ) ? contracts : [ ] ;
const lookAheadText = cs . map ( ( ct ) =>
` ${ ct . skill || '(?)' } : нужды[ ${ ( ct . needs || [ ] ) . join ( ', ' ) } ] ключевые-решения[ ${ ( ct [ 'key-decisions' ] || [ ] ) . join ( ', ' ) } ] критерии[ ${ ( ct [ 'acceptance-criteria' ] || [ ] ) . join ( ', ' ) } ] ` ,
) . join ( '\n' ) ;
2026-06-04 19:43:57 +03:00
const system = [
'Ты — РОУТЕР-НАСТАВНИК. Выбираешь навык по СМЫСЛУ задачи рассуждением (ключевые слова сняты).' ,
'МЕТОД (вшитый брейнсторм): придумай 2–3 варианта-кандидата с разных углов графа → поспорь с собой (почему не сосед-близнец? а если я неправ?) → выбери лучший с обоснованием.' ,
2026-06-05 03:23:11 +03:00
'НЮХ (5.3): размытый/общий шаг («сделай красиво», «настрой») — ПОДОЗРИТЕЛЬНО, не глотай молча.' ,
'ИНТЕРВЬЮЕР (4.4): на размытом/неоднозначном — поставь низкую уверенность и сформулируй уточняющий вопрос (переспрос), не угадывай.' ,
2026-06-05 03:24:49 +03:00
'LOOK-AHEAD: ДО выбора посмотри нужды/ключевые-решения/критерии вызываемых навыков (раздел КОНТРАКТЫ ниже, если есть) и вытащи их вопросы ВПЕРЁД — заложи решения в план заранее.' ,
2026-06-04 19:43:57 +03:00
'L-ядро: L1 предложи дешёвый образец до полной стройки; L3 давай разумный дефолт-на-вето вместо открытого вопроса; L4 порядок решений по влиянию (фундамент раньше мелочи); L5 ограничения как пре-мортем; L7 объяви критерий приёмки заранее.' ,
'ВЫВОД — строго JSON-трасса со слотами: candidates (массив реальных узлов графа), chosen, why_chosen, twins_considered (почему не близнец), confidence (0..1). Пустой слот недопустим.' ,
'Кандидаты ОБЯЗАНЫ быть реальными узлами из каталога ниже — не выдумывай.' ,
2026-06-11 19:09:39 +03:00
'--- КАТАЛОГ УЗЛОВ (skill-каталог) ---' ,
2026-06-04 19:43:57 +03:00
catalogText ,
2026-06-11 19:09:39 +03:00
// W1 (CD-R6-G): project-граф — ОТДЕЛЬНАЯ секция (прунённая карта районов B +
// inline staleness ✅O16), НЕ skill-каталог; заземление groundTrace — против
// ПОЛНОГО нод-графа (groundingGraph в runMentorRound), не против этой карты.
districtLines ? '--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ (прунённая; заземление против полного каталога) ---' : '' ,
districtLines ? districtLines . join ( '\n' ) : '' ,
districtLines ? stalenessLine : '' ,
2026-06-05 03:24:49 +03:00
cs . length ? '--- КОНТРАКТЫ ВЫЗЫВАЕМЫХ НАВЫКОВ (look-ahead) ---' : '' ,
cs . length ? lookAheadText : '' ,
] . filter ( Boolean ) . join ( '\n' ) ;
2026-06-04 19:43:57 +03:00
const user = [
` ЗАДАЧА: ${ prompt } ` ,
plan ? ` ПЛАН (прогресс): ${ typeof plan === 'string' ? plan : JSON . stringify ( plan ) } ` : '' ,
] . filter ( Boolean ) . join ( '\n' ) ;
return { system , user } ;
}
2026-06-11 20:10:42 +03:00
/** FR-4: первый СБАЛАНСИРОВАННЫЙ {...}-срез текста (учёт строк/экранирования) | null. */
function firstBalancedObject ( text ) {
const start = text . indexOf ( '{' ) ;
if ( start < 0 ) return null ;
let depth = 0 ; let inStr = false ; let esc = false ;
for ( let i = start ; i < text . length ; i ++ ) {
const ch = text [ i ] ;
if ( esc ) { esc = false ; continue ; }
if ( inStr ) {
if ( ch === '\\' ) esc = true ;
else if ( ch === '"' ) inStr = false ;
continue ;
}
if ( ch === '"' ) inStr = true ;
else if ( ch === '{' ) depth ++ ;
else if ( ch === '}' ) { depth -- ; if ( depth === 0 ) return text . slice ( start , i + 1 ) ; }
}
return null ;
}
/** Парсер ответа роутера → трасса-объект | null (терпимый к ```json fence).
* FR-4 (финревью 2026-06-11): жадный {…}-regex ловил от первой { до ПОСЛЕДНЕЙ } —
* два JSON-объекта в тексте роняли парс в null; фолбэк — балансный срез ПЕРВОГО. */
2026-06-04 19:43:57 +03:00
export function parseRouterResponse ( text ) {
if ( typeof text !== 'string' ) return null ;
const m = text . match ( /```json\s*([\s\S]*?)```/i ) || text . match ( /(\{[\s\S]*\})/ ) ;
const body = m ? m [ 1 ] : text ;
2026-06-11 20:10:42 +03:00
const tryParse = ( s ) => {
try { const o = JSON . parse ( s ) ; return ( o && typeof o === 'object' ) ? o : null ; }
catch { return null ; }
} ;
const direct = tryParse ( body ) ;
if ( direct ) return direct ;
const balanced = firstBalancedObject ( body ) ;
return balanced ? tryParse ( balanced ) : null ;
2026-06-04 19:43:57 +03:00
}
/**
* Оркестратор: построить промпт → llmCall (инъектируется; в проде callAnthropicAPI) →
* распарсить → МЕХАНИЧЕСКИ провалидировать трассу (слоты) + заземлить кандидатов (ОВ-Д2) →
* воздержание 5.2 при низкой уверенности. Сам выбор — у модели; всё вокруг — механика.
*/
2026-06-05 03:24:49 +03:00
export async function runRouter ( { prompt , plan = null , graph , catalog = null , contracts = [ ] , llmCall , confidenceThreshold = 0.5 } ) {
2026-06-04 19:43:57 +03:00
let trace ;
2026-06-05 03:24:49 +03:00
try { trace = await llmCall ( { buildPrompt : ( ) => buildRouterPrompt ( { prompt , plan , graph , catalog , contracts } ) } ) ; }
2026-06-04 19:43:57 +03:00
catch { return { ok : false , reason : 'сбой вызова модели' , trace : null } ; }
if ( typeof trace === 'string' ) trace = parseRouterResponse ( trace ) ;
if ( ! trace ) return { ok : false , reason : 'пустой/неразборный ответ роутера' , trace : null } ;
const v = validateTrace ( trace ) ;
if ( ! v . ok ) return { ok : false , reason : ` невалидная трасса: пустые слоты [ ${ v . missingSlots . join ( ', ' ) } ] ` , trace } ;
const g = groundTrace ( trace , graph ) ;
if ( ! g . grounded ) return { ok : false , reason : ` выдуманные узлы (нет заземления): [ ${ g . invented . join ( ', ' ) } ] ` , trace } ;
2026-06-07 03:55:52 +03:00
if ( ! chosenIsCandidate ( trace ) )
return { ok : false , reason : ` выбор не среди кандидатов (обход брейнсторма §6.3): chosen= ${ JSON . stringify ( trace . chosen ) } ∉ [ ${ ( trace . candidates || [ ] ) . join ( ', ' ) } ] ` , trace } ;
2026-06-04 19:43:57 +03:00
if ( typeof trace . confidence === 'number' && trace . confidence < confidenceThreshold )
return { ok : true , abstain : true , reason : 'низкая уверенность — воздержание (5.2): переспросить/эскалация' , trace } ;
return { ok : true , abstain : false , trace } ;
}