#!/usr/bin/env node /** * mentor-seam (§6) — живой шов наставника. ч.1 buildMentorPrompt: обёртка buildRouterPrompt * + проверенный контекст (из A, данные) + журнал переговоров + граф-секция (выход B, данные). * НЕ импортирует A/B — секция/контекст ПЕРЕДАЮТСЯ (автономность C; боевая проводка передаёт * реальные). Граф = ОТДЕЛЬНАЯ секция (CD-R6-G); контекст = ПРОВЕРЕННЫЙ артефакт (CD-R6-H). * FR-2 (финревью 2026-06-11, снял Д-1а-обход): карту районов+staleness рендерит БАЗА * buildRouterPrompt в system (W1-канон, CD-R6-G); seam НЕ дублирует. Extras добавляют * их в user ТОЛЬКО при пустом/отсутствующем layer0 — маркер ОТСУТСТВИЯ (F-C6) + staleness (✅O16). */ import { buildRouterPrompt } from './router-engine.mjs'; /** A8 (нах.F6): ДР-1 гранулярность — поведенческая инъекция в system. * VA-1 (финревью 3/5): ЕДИНЫЙ источник — экспортируется (mentor-verdict импортирует, * дубль-литерал снят). */ export const DR1_LINE = 'ЕДИНИЦА ПЛАНА (ДР-1): растяжка до следующей НЕИЗВЕСТНОСТИ; громкость церемонии по риску — detectHighRisk (atomic/trivial → тихо; прод/многошаг/разрушительное → громко).'; 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 ?? '?'}`; } /** VA-1 (финревью 3/5): ЕДИНЫЙ рендер контекста — экспортируется (mentor-verdict * импортирует; дубль-рендер с дрейфом снят). VA-2 («сигнал-всегда», зеркало F-C6): * пустой список НЕ молчит — наставник видит, что контекста НЕТ (VA-9 ловит на печати, * а круги переговоров идут до неё). */ export function renderVerifiedContext(vc) { const list = Array.isArray(vc) ? vc : []; if (!list.length) return '--- ПРОВЕРЕННЫЙ КОНТЕКСТ ПУСТ (0 записей — печать заблокирует VA-9) ---'; const lines = list.map((e) => `[${e.kind || '?'}] ${e.claim || ''} (${e.ref || ''})`); return ['--- ПРОВЕРЕННЫЙ КОНТЕКСТ (резолв цитат, НЕ истина — ревью владельца обязателен) ---', ...lines].join('\n'); } /** VA-1: единый рендер переговоров (export — см. выше). Пустой лог легитимен * (круг 1 без истории) → '' без маркера. */ export function renderNegotiation(log) { 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'); } /** Резолв 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); } /** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы + * рекомендация роутера (ID резолвятся в имена через registry). recommendedChain=null → * классификатор недоступен (не сверять). */ export function renderSkillContext({ declared = [], recommendedChain = null, registry = null } = {}) { const dec = declared.length ? declared.join(', ') : '(не объявлены)'; const rec = recommendedChain === null ? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)' : (Array.isArray(recommendedChain) && recommendedChain.length ? recommendedChain.map((id) => resolveNodeName(id, registry)).join(', ') : '(роутер ничего не порекомендовал)'); return `--- СКИЛЫ ---\nОбъявлены в плане: ${dec}\nРекомендация роутера: ${rec}\n` + 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.'; } /** * Построить промпт наставника = 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}. */ export function buildMentorPrompt({ prompt = '', plan = null, graphSection = null, verifiedContext = [], negotiationLog = [], catalog = null, contracts = [] } = {}) { const base = buildRouterPrompt({ prompt, plan, graph: graphSection, catalog, contracts }); const baseRendersGraph = graphSection && Array.isArray(graphSection.layer0) && graphSection.layer0.length > 0; const extras = [ baseRendersGraph ? '' : renderDistricts(graphSection), baseRendersGraph ? '' : renderStaleness(graphSection), 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: → конструктивное воздержание с сигналом * 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 }; } // 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 }; } 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)` }; }