2026-06-11 18:13:45 +03:00
#!/usr/bin/env node
/**
* mentor-seam (§6) — живой шов наставника. ч.1 buildMentorPrompt: обёртка buildRouterPrompt
* + проверенный контекст (из A, данные) + журнал переговоров + граф-секция (выход B, данные).
* НЕ импортирует A/B — секция/контекст ПЕРЕДАЮТСЯ (автономность C; боевая проводка передаёт
* реальные). Граф = ОТДЕЛЬНАЯ секция (CD-R6-G); контекст = ПРОВЕРЕННЫЙ артефакт (CD-R6-H).
2026-06-11 20:10:42 +03:00
* FR-2 (финревью 2026-06-11, снял Д-1а-обход): карту районов+staleness рендерит БАЗА
* buildRouterPrompt в system (W1-канон, CD-R6-G); seam НЕ дублирует. Extras добавляют
* их в user ТОЛЬКО при пустом/отсутствующем layer0 — маркер ОТСУТСТВИЯ (F-C6) + staleness (✅O16).
2026-06-11 18:13:45 +03:00
*/
import { buildRouterPrompt } from './router-engine.mjs' ;
2026-06-11 20:10:42 +03:00
/** A8 (нах.F6): ДР-1 гранулярность — поведенческая инъекция в system.
* VA-1 (финревью 3/5): ЕДИНЫЙ источник — экспортируется (mentor-verdict импортирует,
* дубль-литерал снят). */
export const DR1 _LINE = 'ЕДИНИЦА ПЛАНА (ДР-1): растяжка до следующей НЕИЗВЕСТНОСТИ; громкость церемонии по риску — detectHighRisk (atomic/trivial → тихо; прод/многошаг/разрушительное → громко).' ;
2026-06-11 18:13:45 +03:00
function renderDistricts ( graphSection ) {
const layer0 = graphSection && Array . isArray ( graphSection . layer0 ) ? graphSection . layer0 : [ ] ;
// F-C6 (sharp-edges): слой-0 ВСЕГДА (ДР-3) + «сигнал ВСЕГДА» (§5.4) — отсутствие карты
// НЕ молчит: явный маркер видят и наставник, и владелец (зеркало staleness-философии).
if ( ! layer0 . length ) return '--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ ОТСУТСТВУЕТ (слой-0 не передан/пуст — ДР-3 требует ВСЕГДА) ---' ;
const lines = layer0 . map ( ( d ) => {
const top = Array . isArray ( d . topNodes ) ? d . topNodes . map ( ( n ) => n . id ) . join ( ', ' ) : '' ;
return ` ${ d . district } ( ${ d . nodeCount } узлов) ${ top ? ` — топ: ${ top } ` : '' } ` ;
} ) ;
return [ '--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ (layer-0, прунённая) ---' , ... lines ] . join ( '\n' ) ;
}
function renderStaleness ( graphSection ) {
const st = graphSection && graphSection . staleness ;
if ( ! st ) return '' ;
return ` --- СВЕЖЕСТЬ ГРАФА (staleness) --- stale= ${ ! ! st . stale } commits_behind= ${ st . commits _behind ? ? '?' } uncommitted= ${ st . uncommitted ? ? '?' } ` ;
}
2026-06-11 20:10:42 +03:00
/** VA-1 (финревью 3/5): ЕДИНЫЙ рендер контекста — экспортируется (mentor-verdict
* импортирует; дубль-рендер с дрейфом снят). VA-2 («сигнал-всегда», зеркало F-C6):
* пустой список НЕ молчит — наставник видит, что контекста НЕТ (VA-9 ловит на печати,
* а круги переговоров идут до неё). */
export function renderVerifiedContext ( vc ) {
2026-06-11 18:13:45 +03:00
const list = Array . isArray ( vc ) ? vc : [ ] ;
2026-06-11 20:10:42 +03:00
if ( ! list . length ) return '--- ПРОВЕРЕННЫЙ КОНТЕКСТ ПУСТ (0 записей — печать заблокирует VA-9) ---' ;
2026-06-11 18:13:45 +03:00
const lines = list . map ( ( e ) => ` [ ${ e . kind || '?' } ] ${ e . claim || '' } ( ${ e . ref || '' } ) ` ) ;
return [ '--- ПРОВЕРЕННЫЙ КОНТЕКСТ (резолв цитат, НЕ истина — ревью владельца обязателен) ---' , ... lines ] . join ( '\n' ) ;
}
2026-06-11 20:10:42 +03:00
/** VA-1: единый рендер переговоров (export — см. выше). Пустой лог легитимен
* (круг 1 без истории) → '' без маркера. */
export function renderNegotiation ( log ) {
2026-06-11 18:13:45 +03:00
const list = Array . isArray ( log ) ? log : [ ] ;
if ( ! list . length ) return '' ;
const lines = list . map ( ( e ) => ` круг ${ e . round } [ ${ e . side } ] ${ e . utterance } — обоснование: ${ e . justification || '' } ` ) ;
return [ '--- ПЕРЕГОВОРЫ ЗАДАЧИ (история кругов) ---' , ... lines ] . join ( '\n' ) ;
}
2026-06-13 17:15:14 +03:00
/** Резолв ID узла → «#N — имя» через registry.indexById; нет узла/реестра → голый #N (fail-safe). */
function resolveNodeName ( id , registry ) {
const node = registry && registry . indexById && typeof registry . indexById . get === 'function'
? registry . indexById . get ( id ) : null ;
const label = node && ( node . name || node . slug ) ;
return label ? ` ${ id } — ${ label } ` : String ( id ) ;
}
2026-06-13 11:38:09 +03:00
/** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы +
2026-06-13 17:15:14 +03:00
* рекомендация роутера (ID резолвятся в имена через registry). recommendedChain=null →
* классификатор недоступен (не сверять). */
export function renderSkillContext ( { declared = [ ] , recommendedChain = null , registry = null } = { } ) {
2026-06-13 11:38:09 +03:00
const dec = declared . length ? declared . join ( ', ' ) : '(не объявлены)' ;
const rec = recommendedChain === null
? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)'
2026-06-13 17:15:14 +03:00
: ( Array . isArray ( recommendedChain ) && recommendedChain . length
? recommendedChain . map ( ( id ) => resolveNodeName ( id , registry ) ) . join ( ', ' )
: '(роутер ничего не порекомендовал)' ) ;
2026-06-13 11:38:09 +03:00
return ` --- СКИЛЫ --- \n Объявлены в плане: ${ dec } \n Рекомендация роутера: ${ rec } \n `
+ 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.' ;
}
2026-06-11 18:13:45 +03:00
/**
2026-06-11 20:10:42 +03:00
* Построить промпт наставника = buildRouterPrompt(graph=граф-секция B; районы+staleness
* рендерит БАЗА в system — W1-канон, FR-2 финревью 2026-06-11) + проверенный контекст +
* журнал переговоров — в user (волатильное); system = router-system + ДР-1 (A8).
* Extras рендерят районы/staleness ТОЛЬКО при пустом/отсутствующем layer0 (база молчит):
* маркер ОТСУТСТВИЯ карты (F-C6 «сигнал-всегда») + staleness-строка (✅O16). {system,user}.
2026-06-11 18:13:45 +03:00
*/
export function buildMentorPrompt ( { prompt = '' , plan = null , graphSection = null , verifiedContext = [ ] , negotiationLog = [ ] , catalog = null , contracts = [ ] } = { } ) {
const base = buildRouterPrompt ( { prompt , plan , graph : graphSection , catalog , contracts } ) ;
2026-06-11 20:10:42 +03:00
const baseRendersGraph = graphSection && Array . isArray ( graphSection . layer0 ) && graphSection . layer0 . length > 0 ;
2026-06-11 18:13:45 +03:00
const extras = [
2026-06-11 20:10:42 +03:00
baseRendersGraph ? '' : renderDistricts ( graphSection ) ,
baseRendersGraph ? '' : renderStaleness ( graphSection ) ,
2026-06-11 18:13:45 +03:00
renderVerifiedContext ( verifiedContext ) ,
renderNegotiation ( negotiationLog ) ,
] . filter ( Boolean ) . join ( '\n' ) ;
return {
system : [ base . system , DR1 _LINE ] . join ( '\n' ) ,
user : [ base . user , extras ] . filter ( Boolean ) . join ( '\n' ) ,
} ;
}
// Task 5 — runMentorRound + петля: импорт здесь валиден (ESM hoisting).
import { parseRouterResponse , validateTrace , groundTrace , chosenIsCandidate } from './router-engine.mjs' ;
/** §6.3 SE-7: потолок кругов наставника per-ЗАДАЧЕ (× MENTOR_PROBE_CAP probe в D). */
export const MENTOR _ROUND _CAP = 3 ;
/** §5.3 F7 (✅O19-зеркало): дозапросов соседнего района на КРУГ — дешёвое чтение
* КАРТЫ (districtDetail из B), не файлов. Сверх cap → отказ. */
export const DISTRICT _PROBE _CAP = 2 ;
/**
* Один круг наставника: buildMentorPrompt → llmCall (инъект; живой транспорт — владелец) →
* пайплайн runRouter (validateTrace/groundTrace/chosenIsCandidate/abstain). wired:true только
* при реальном завершённом вызове (SE-R6-6: сбой/stub → wired:false, НЕ суд).
* A2 (SE2): groundingGraph = ПОЛНЫЙ нод-граф (заземление groundTrace, CD-R6-G «против
* полного»); graphSection = прунённая карта районов B (в промпт). НЕ путать — разные
* формы, своп ломает заземление.
* F7 (A6/Н -3): трасса с request_district:<name> → конструктивное воздержание с сигналом
* escalateDistrict (контроллер фетчит districtDetail(name) из B в graphSection следующего
* круга); districtProbesUsed ≥ DISTRICT_PROBE_CAP → отказ (бюджет круга исчерпан).
* F-C5 (sharp-edges, secure default): дефолт districtProbesUsed = DISTRICT_PROBE_CAP —
* ОМИССИЯ счётчика НЕ даёт безлимитные дозапросы (configuration cliff); реальный
* per-round счётчик ОБЯЗАН вести производитель (C2-W3), передавая фактическое значение.
*/
export async function runMentorRound ( { prompt , plan = null , groundingGraph , graphSection = null , verifiedContext = [ ] , negotiationLog = [ ] , catalog = null , contracts = [ ] , llmCall , confidenceThreshold = 0.5 , districtProbesUsed = DISTRICT _PROBE _CAP } ) {
let trace ;
try {
trace = await llmCall ( { buildPrompt : ( ) => buildMentorPrompt ( { prompt , plan , graphSection , verifiedContext , negotiationLog , catalog , contracts } ) } ) ;
} catch {
return { ok : false , wired : false , abstain : false , escalateDistrict : null , reason : 'сбой вызова наставника' , trace : null } ;
}
if ( typeof trace === 'string' ) trace = parseRouterResponse ( trace ) ;
if ( ! trace ) return { ok : false , wired : false , abstain : false , escalateDistrict : null , reason : 'пустой/неразборный ответ наставника' , trace : null } ;
const reqDistrict = typeof trace . request _district === 'string' ? trace . request _district . trim ( ) : '' ;
if ( reqDistrict ) {
if ( Number ( districtProbesUsed ) >= DISTRICT _PROBE _CAP ) {
return { ok : false , wired : true , abstain : false , escalateDistrict : null , reason : ` дозапрос района сверх DISTRICT_PROBE_CAP= ${ DISTRICT _PROBE _CAP } на круг — отказ (F7) ` , trace } ;
}
2026-06-11 20:10:42 +03:00
// VA-3 (финревью 3/5): при непустой карте (layer0) имя района проверяется — опечатка/
// выдумка наставника НЕ эскалируется молча к пустой детали districtDetail. Пустая/
// отсутствующая карта → валидировать нечем, старое поведение (контроллер разрулит).
const known = ( graphSection && Array . isArray ( graphSection . layer0 ) ) ? graphSection . layer0 : [ ] ;
if ( known . length && ! known . some ( ( d ) => d && d . district === reqDistrict ) ) {
return { ok : false , wired : true , abstain : false , escalateDistrict : null , reason : ` запрошенный район « ${ reqDistrict } » отсутствует в карте районов — отказ (VA-3) ` , trace } ;
}
2026-06-11 18:13:45 +03:00
return { ok : true , wired : true , abstain : true , escalateDistrict : reqDistrict , reason : 'дозапрос соседнего района — конструктивное воздержание (F7, ДР-P1)' , trace } ;
}
const v = validateTrace ( trace ) ;
if ( ! v . ok ) return { ok : false , wired : true , abstain : false , escalateDistrict : null , reason : ` невалидная трасса: [ ${ v . missingSlots . join ( ', ' ) } ] ` , trace } ;
const g = groundTrace ( trace , groundingGraph ) ;
if ( ! g . grounded ) return { ok : false , wired : true , abstain : false , escalateDistrict : null , reason : ` выдуманные узлы: [ ${ g . invented . join ( ', ' ) } ] ` , trace } ;
if ( ! chosenIsCandidate ( trace ) ) return { ok : false , wired : true , abstain : false , escalateDistrict : null , reason : 'выбор не среди кандидатов (§6.3)' , trace } ;
if ( trace . confidence < confidenceThreshold ) return { ok : true , wired : true , abstain : true , escalateDistrict : null , reason : 'низкая уверенность — воздержание (5.2): переспрос/эскалация' , trace } ;
return { ok : true , wired : true , abstain : false , escalateDistrict : null , trace } ;
}
/**
* Петля кругов (§4/6.4): крутит runRoundImpl пока не согласовано ЛИБО потолок кругов
* (→ эскалация владельцу, симметрия ДР-6 в ОБА направления). resolved = ok && !abstain.
* Каждый круг — забота вызывающего записать в mentor-journal (вне чистой петли). nowRound —
* текущий счётчик кругов задачи (из roundCount). @returns {{resolved, escalate, round, last}}
*/
export async function mentorLoop ( { runRoundImpl , nowRound = 0 , cap = MENTOR _ROUND _CAP } ) {
let round = nowRound ;
let last = null ;
while ( round < cap ) {
round += 1 ;
last = await runRoundImpl ( round ) ;
if ( last && last . ok && ! last . abstain ) return { resolved : true , escalate : false , round , last } ;
}
return { resolved : false , escalate : true , round , last , reason : ` ${ cap } круга без согласия → к владельцу (ДР-6) ` } ;
}