#!/usr/bin/env node /** * enforce-mentor-on-plan-write — активационная обёртка наставника (runbook Этап 1, W3/W7). * PostToolUse на запись плана (PLAN_PATH_RE + Write — канон судьи): производит вердикт * наставника (onPlanWrite: task-id ✅O17 + binding нах.F4 + журнал ДР-6) и персистит * вердикт/журнал/task-id. ПРОИЗВОДИТЕЛЬ, НЕ ГЕЙТ: exit всегда allow — печать блокирует * freeze-gate в пути судьи (T6). Рубильник mentorSeamActive: без флага+ключа — $0 no-op. */ import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs'; import { mentorSeamActive, resolveMentorLlmKey } from './mentor-gate-config.mjs'; import { PLAN_PATH_RE, SPEC_PATH_RE, ownerSealOpenForEvent } from './enforce-judge-gate.mjs'; import { sealablePlan, sealableArtifact, judgedHashOf, ownerSealActionForContent } from './seal-orchestration.mjs'; import { planId } from './plan-lock.mjs'; // owner-seal в тупике с наставником (фикс 2026-06-21): тот же источник грантов, что у судьи // (терминальный грант владельца), чтобы наставник чтил owner-seal и пропускал к судье. import { loadTerminalGrants, loadConsumed } from './escape-grant.mjs'; import { onPlanWrite, onSpecWrite } from './on-plan-write.mjs'; import { parseVerifiedContext } from './plan-verified-context.mjs'; // Мерж роутер↔наставник (Р8): наставник зовёт мозг роутера classify() как функцию + грузит // граф (loadRegistry). parsePlanSkills/extractPlanGoal — объявленные скилы + цель из плана. import { parsePlanSkills, extractPlanGoal } from './plan-skills.mjs'; import { loadRegistry } from './registry-load.mjs'; import { classify } from './router-classifier.mjs'; import { loadMentorJournal, persistMentorJournal, persistMentorVerdict } from './mentor-journal-store.mjs'; import { loadTaskId, saveTaskId, deriveTaskId } from './router-task-id.mjs'; import { callAnthropicAPI } from './router-classifier.mjs'; import { CLASSIFIER_MODEL, HEAVY_LLM_TIMEOUT_MS } from './router-config.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; import { resolveSessionId } from './enforce-supreme-gate.mjs'; // Волна 7 (двухуровневые переговоры §6): наставник — surface + счётчик + эскалация → карточка. import { buildArbitrationCard } from './arbitration-card.mjs'; import { formatMentorObjection } from './objection-format.mjs'; import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs'; import { parseNegotiationSection, arbitrationRequested } from './negotiation-section.mjs'; import { bumpMentorNoGo, MENTOR_ESCALATE_AFTER } from './mentor-nogo-counter.mjs'; // Способ B (Фаза 2): наставник НЕ печатает — на GO лишь записывает подписанное одобрение // (mentor-GO, привязка к plan_hash). Печать делает судья (хук ПОСЛЕ наставника) при валидном mentor-GO. import { buildMentorGo, persistMentorGo, clearMentorGo } from './mentor-go-store.mjs'; import { classifyJudgeOutcome } from './verdict-outcome-line.mjs'; import { pushVerdict } from './verdict-surface-store.mjs'; // SP2c-2: загрузчик памяти кругов M-side (свои замечания + M-доводы + diff + замечание судьи // при возврате) — инъектируется в runMentorOnPlanWrite, протягивается до построителя вердикта. import { buildRoundMemory } from './round-memory-store.mjs'; // Пининг роутера (спека роутер-реестр §4): пер-сессионный пин совета по (task_id, goalHash) — // переписка шагов не зовёт LLM заново. Инъектируется в onPlanWrite через runMentorOnPlanWrite. import { loadRouterPin, saveRouterPin } from './router-pin-store.mjs'; /** * Волна 7 (§6): сообщение арбитража при 3 NO-GO наставника — дословное замечание + * позиция контроллера (раздел «Переговоры» плана) + 3 выбора + аффорданс. Чистая. */ export function buildMentorArbitrationMessage(res, planContent, n) { const neg = parseNegotiationSection(planContent); const position = neg.length ? neg[neg.length - 1].position : '(позиция не указана в разделе «Переговоры» плана)'; const card = buildArbitrationCard({ side: 'mentor', level: 'L1', round: n, objectionVerbatim: formatMentorObjection(res) || '(нет текста замечания)', controllerPositionVerbatim: position, // SP3-c: owner-seal-метка (тот же хеш, что sealTurnProd) → владельцу есть откуда взять escape. sealAction: ownerSealActionForContent(planContent), }); const opts = card.options.map((o) => `• ${o.label}: ${o.whatChanges}`).join('\n'); return [ '[mentor] ' + card.title, `Замечание наставника:\n${card.objection}`, `Позиция контроллера:\n${card.position}`, `Что меняет выбор:\n${opts}`, 'Скажи «объясни подробнее». Решение — через escape/вейвер владельца.', ].join('\n\n'); } /** * Фаза 1 (канал замечаний, Р2): чистое решение «что отдать контроллеру» по результату * наставника. Только настоящий NO-GO (wired && !ok) → block:true с ПОЛНЫМ текстом замечания * (через рабочий exit-2 канал); на 3-м заходе — карточка арбитража. GO/degraded → block:false. * * owner-seal (фикс 2026-06-21): когда владелец подписал owner-seal:<хеш тела> и грант ОТКРЫТ * (ownerSealOpen===true), наставник НЕ блокирует — пропускает действие к судье, который печатает * через ownerSealOpen. Перевешивает оба случая блокировки (содержательный NO-GO и degraded) — * как уже делает судья. Раньше оркестратор при exit-2 наставника судью не звал, и owner-seal был * мёртвой проводкой в тупике с наставником. GO-путь не меняется (owner-seal там не нужен). */ export function decideMentorObjection({ res, planContent, n, ownerSealOpen = false } = {}) { // degraded (наставник не дозвонился, спека §9): block:true с «не смог дозвониться», // одобрения нет (recordMentorGo:false), это НЕ NO-GO (escalation не растёт). if (res && res.ran && res.wired === false) { if (ownerSealOpen) return { block: false, recordMentorGo: false, ownerSealOverride: true }; return { block: true, degraded: true, recordMentorGo: false, message: buildDegradedFeedback({ side: 'mentor', reason: res.reason || 'транспорт недоступен' }), }; } // Р7/мерж: содержательное «переделай» обязано заворачивать — явная кнопка decision='NO-GO' // блокирует наряду со сломанным вердиктом (ok!==true). Раньше блокировал только сломанный → // содержательный NO-GO тонул как GO (круг L1 не работал). const decision = res && res.verdict && res.verdict.decision; const blocked = !!(res && res.wired === true && (decision === 'NO-GO' || res.ok !== true)); if (!blocked) { // GO (wired && ok && decision==='GO'): наставник одобрил ЭТОТ план → записать mentor-GO. const recordMentorGo = !!(res && res.wired === true && res.ok === true && decision === 'GO'); return { block: false, recordMentorGo }; } // owner-seal перевешивает NO-GO наставника (фикс 2026-06-21): владелец подписал → пропуск к судье. if (ownerSealOpen) return { block: false, recordMentorGo: false, ownerSealOverride: true }; // SP2d: карточка арбитража на 3-м круге (потолок) ИЛИ когда контроллер пишет маркер // `**Арбитраж:**` в плане (выход на ЛЮБОМ круге, дизайн §7). const message = (n >= MENTOR_ESCALATE_AFTER || arbitrationRequested(String(planContent ?? ''))) ? buildMentorArbitrationMessage(res, String(planContent ?? ''), n) : buildObjectionFeedback({ side: 'mentor', text: formatMentorObjection(res) }); return { block: true, recordMentorGo: false, message }; } /** Адаптер llmCall (паттерн судьи [enforce-judge-gate.mjs:167-177]): throw НЕ глотаем — * его ловит runMentorVerdict → wired:false (SE-R6-6, не суд). */ export function buildLlmCall({ apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI, perAttemptTimeoutMs = HEAVY_LLM_TIMEOUT_MS }) { // perAttemptTimeoutMs: вердикт наставника тяжёлый (~25-32с) — дефолт 30с давал // таймаут→degraded. 300с укладывает латентность deepseek-v4-pro (systematic-debugging 2026-06-14). return async ({ buildPrompt }) => transport(buildPrompt(), { apiKey, model, perAttemptTimeoutMs }); } /** * Чистый производитель: inert → {ran:false}; не план-Write → {ran:false}; план без * steps-json → {ran:false, reason} (вердикт НЕ фабрикуется — печать всё равно fail-CLOSE * у судьи [enforce-judge-gate.mjs:79]); иначе onPlanWrite + персист. ВСЕ deps инъектируются. * roundMemoryImpl (SP2c-2): загрузчик памяти кругов M-side ({stage,content,taskId}→roundMemory); * null → круг слеп (память пуста, backward-compat). */ export async function runMentorOnPlanWrite(event, { mentorActiveImpl, llmCall, loadJournalImpl, persistJournalImpl, persistVerdictImpl, loadTaskIdImpl, persistTaskIdImpl, journalKey, graphSectionImpl, nowMs = null, classifyImpl = null, registryImpl = null, roundMemoryImpl = null, pinLoadImpl = null, pinSaveImpl = null, } = {}) { if (!mentorActiveImpl()) return { ran: false, reason: 'mentor inert ($0)' }; const tool = event && event.tool_name; const filePath = String((event && event.tool_input && event.tool_input.file_path) || ''); const content = String((event && event.tool_input && event.tool_input.content) ?? ''); // Фаза 3 (Р6, отдельный spec-путь): запись СПЕКИ тоже будит наставника. Вердикт по спеке // (видит контекст: verified-context + переговоры), binding к хешу артефакта спеки (тот же, // чем судья печатает gate1). Один сценарий со спекой и планом — наставник одобряет оба. if (tool === 'Write' && SPEC_PATH_RE.test(filePath)) { let specHash; try { specHash = judgedHashOf(sealableArtifact(content)); } catch { specHash = null; } if (!specHash) return { ran: false, reason: 'спека без артефакт-хеша — вердикт не фабрикуется' }; const journalS = loadJournalImpl(); const taskIdForPromptS = deriveTaskId({ existingTaskId: loadTaskIdImpl(), firstPlanHash: specHash }); const negotiationLogS = (journalS.entries || []) .map((e) => e && e.payload) .filter((p) => p && p.task_id === taskIdForPromptS); let graphSectionS = null; try { graphSectionS = graphSectionImpl(); } catch { graphSectionS = null; } const verifiedContextS = parseVerifiedContext(content); // Фикс silent-swallow (Уроки №7): симметрично план-ветке — throw в регионе спеки → видимый // degraded (wired:false), не молчаливый реджект в catch main(). let rs; try { // SP2c-2: память кругов M-side спеки (свои замечания + M-доводы + diff + замечание судьи). const roundMemoryS = roundMemoryImpl ? roundMemoryImpl({ stage: 'spec', content, taskId: taskIdForPromptS }) : {}; rs = await onSpecWrite({ specContent: content, specHash, existingTaskId: loadTaskIdImpl(), persistTaskIdImpl, llmCall, journalEntries: journalS.entries, journalKey, nowMs, verifiedContext: verifiedContextS, negotiationLog: negotiationLogS, graphSection: graphSectionS, roundMemory: roundMemoryS, }); } catch (e) { return { ran: true, ok: false, wired: false, reason: `наставник-путь (спека) сорвался: ${e && e.message ? e.message : e}`, taskId: taskIdForPromptS, planHash: specHash, verdict: null }; } try { persistVerdictImpl({ ok: rs.ok, wired: rs.wired, reason: rs.reason ?? null, planHash: specHash, verdict: rs.verdict }); } catch { /* best-effort */ } if (rs.journalOk && rs.journal) { try { persistJournalImpl(rs.journal); } catch { /* best-effort (SE10) */ } } return { ran: true, ok: rs.ok, wired: rs.wired, reason: rs.reason, taskId: rs.taskId, planHash: specHash, verdict: rs.verdict }; } if (tool !== 'Write' || !PLAN_PATH_RE.test(filePath)) return { ran: false, reason: 'не запись плана/спеки' }; let steps; try { steps = sealablePlan(content).steps; } catch { steps = null; } if (!Array.isArray(steps) || steps.length === 0) { return { ran: false, reason: 'план без steps-json блока — вердикт не фабрикуется (печать fail-CLOSE у судьи)' }; } const journal0 = loadJournalImpl(); // F-C2-4: загруженная цепь, не [] // W-3 (sharp-edges 2026-06-12): в промпт — переговоры ТОЛЬКО текущей задачи (тот же // deriveTaskId, что внутри onPlanWrite ✅O17: существующий побеждает, иначе якорь плана). const taskIdForPrompt = deriveTaskId({ existingTaskId: loadTaskIdImpl(), firstPlanHash: planId(steps) }); const negotiationLog = (journal0.entries || []) .map((e) => e && e.payload) .filter((p) => p && p.task_id === taskIdForPrompt); let graphSection = null; try { graphSection = graphSectionImpl(); } catch { graphSection = null; } // F-C6: null → маркер ОТСУТСТВИЯ const verifiedContext = parseVerifiedContext(content); // Мерж (Р8): объявленные в плане скилы + цель → onPlanWrite зовёт classify() (мозг роутера), // кладёт «рекомендация vs объявлено» в вердикт. registryImpl?.() — граф/карточки nodes.yaml. const declaredSkills = parsePlanSkills(content); const planGoal = extractPlanGoal(content); let registry = null; try { registry = typeof registryImpl === 'function' ? registryImpl() : null; } catch { registry = null; } const planHash = planId(steps); // Фикс silent-swallow (Уроки №7): срыв В РЕГИОНЕ наставника (renderSkillContext в on-plan-write.mjs // и пр.) раньше всплывал в молчаливый catch main() → ни вердикта, ни снимка, ни печати, ни сигнала // (в снимке «упало» неотличимо от «считает»). Теперь любой throw → ВИДИМЫЙ degraded (wired:false): // снимок mentor:plan=degraded + блок «наставник сорвался» → контроллер видит и перезапускает. let r; try { // SP2c-2: память кругов M-side плана (свои замечания + M-доводы + diff + замечание судьи). const roundMemoryP = roundMemoryImpl ? roundMemoryImpl({ stage: 'plan', content, taskId: taskIdForPrompt }) : {}; r = await onPlanWrite({ planSteps: steps, existingTaskId: loadTaskIdImpl(), persistTaskIdImpl, llmCall, journalEntries: journal0.entries, journalKey, nowMs, verifiedContext, negotiationLog, graphSection, classifyImpl, registry, declaredSkills, planGoal, pinLoadImpl, pinSaveImpl, roundMemory: roundMemoryP, }); } catch (e) { return { ran: true, ok: false, wired: false, reason: `наставник-путь сорвался: ${e && e.message ? e.message : e}`, taskId: taskIdForPrompt, planHash, verdict: null, routerClassification: null }; } try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ } if (r.journalOk && r.journal) { try { persistJournalImpl(r.journal); } catch { /* best-effort (SE10) */ } } return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict, routerClassification: r.routerClassification ?? null }; } async function main() { try { const event = parseEventJson(await readStdin()); const fs = (await import('node:fs')).default; const dir = runtimeDir(); const sess = resolveSessionId(event); const res = await runMentorOnPlanWrite(event, { mentorActiveImpl: () => mentorSeamActive(), // «Оба строго» (2026-06-12): СВОЙ ключ наставника, общий ROUTER_LLM_KEY не фолбэк. llmCall: buildLlmCall({ apiKey: resolveMentorLlmKey() }), loadJournalImpl: () => loadMentorJournal({ sessionId: sess, runtimeDir: dir }), persistJournalImpl: (j) => persistMentorJournal({ journal: j, sessionId: sess, runtimeDir: dir }), persistVerdictImpl: (rec) => persistMentorVerdict({ record: rec, sessionId: sess, runtimeDir: dir }), loadTaskIdImpl: () => loadTaskId({ sessionId: sess, runtimeDir: dir, fsImpl: fs }), persistTaskIdImpl: (id) => saveTaskId({ taskId: id, sessionId: sess, runtimeDir: dir, fsImpl: fs }), journalKey: resolveReceiptKey(), // Боевой граф B — следующий шаг после обкатки (runbook-нота T7): null → промпт // наставника несёт явный маркер «КАРТА РАЙОНОВ ОТСУТСТВУЕТ» (F-C6, не тихо). graphSectionImpl: () => null, // Мерж (Р8): мозг роутера classify() как функция (3 слоя + граф/карточки nodes.yaml, // код classify/registry-load НЕ тронут — новый вызыватель). classify сам берёт свой // ключ/транспорт; сбой ловит onPlanWrite → fail-safe (план без скил-сверки, §5). classifyImpl: async (goal, registry) => classify(goal, registry, { skipPrefilter: true }), registryImpl: () => { try { return loadRegistry({ useCache: false }); } catch { return null; } }, // SP2c-2: реальный загрузчик памяти кругов M-side из стора (fail-quiet внутри // buildRoundMemory). baseDir = runtime; side='mentor' → M-дорожка + замечание судьи при возврате. roundMemoryImpl: ({ stage, content, taskId }) => buildRoundMemory({ taskId, stage, side: 'mentor', currentContent: content, baseDir: dir }), // Пининг роутера (спека роутер-реестр §4): реальный пин-стор сессии. Пин-попадание по // (taskId, goalHash) → совет переиспользуется (classify не зовётся); промах → classify + save. pinLoadImpl: ({ taskId, planGoal }) => loadRouterPin({ taskId, planGoal, sessionId: sess }), pinSaveImpl: ({ taskId, planGoal, classification }) => saveRouterPin({ taskId, planGoal, classification, sessionId: sess }), }); if (res && res.ran) { // SP1: громкая видимость вердикта наставника (best-effort, fail-quiet). try { const mentorOutcome = classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision }); const mentorReason = (res.verdict && res.verdict.recommendation) || res.reason || ''; pushVerdict(sess, { outcome: mentorOutcome, gate: 'mentor', round: null, version: null, reason: mentorReason }); // Видимость «всё в лоб»: снимок-стадии наставника + роутера (баннер + персистентный снимок). const fpV = String((event.tool_input && event.tool_input.file_path) || ''); const stageM = SPEC_PATH_RE.test(fpV) ? 'mentor:spec' : 'mentor:plan'; const { writeStage } = await import('./verdict-surface-store.mjs'); writeStage(sess, { stage: stageM, hash: res.planHash, status: mentorOutcome, reason: mentorReason, ts: Date.now() }); if (res.routerClassification) { const { classifyRouterOutcome } = await import('./verdict-outcome-line.mjs'); const routerOutcome = classifyRouterOutcome(res.routerClassification); const rc = res.routerClassification.recommended_chain; const routerReason = Array.isArray(rc) && rc.length ? `рекомендует: ${rc.join(', ')}` : 'скил не требуется'; pushVerdict(sess, { outcome: routerOutcome, gate: 'router', round: null, version: null, reason: routerReason }); writeStage(sess, { stage: 'router', hash: res.planHash, status: routerOutcome, reason: routerReason, ts: Date.now() }); } } catch { /* fail-quiet */ } // Р7/§3.4: счётчик L1 растёт на NO-GO = содержательный decision='NO-GO' ИЛИ сломанный // вердикт (ok!==true). degraded (wired:false) не считается (escalation L1 не растёт). const verdictDecision = res.verdict && res.verdict.decision; const blocked = res.wired === true && (verdictDecision === 'NO-GO' || res.ok !== true); // Фаза 4 / SP2c-3: счётчик per-стадия — ключ (task-id + stage). stage из пути события. const fpStage = String((event.tool_input && event.tool_input.file_path) || ''); const stageNoGo = SPEC_PATH_RE.test(fpStage) ? 'spec' : (PLAN_PATH_RE.test(fpStage) ? 'plan' : null); const n = bumpMentorNoGo({ taskId: res.taskId, sessionId: sess, stage: stageNoGo, blocked }); // Фаза 1 (Р2): на NO-GO/degraded — ПОЛНЫЙ текст доходит до контроллера через рабочий // exit-2 канал (подтверждён Фазой 0). На 3-м NO-GO — карточка арбитража. const planContent = String((event.tool_input && event.tool_input.content) ?? ''); // SP2c-1: память кругов — снимок версии (наставник раньше судьи в цепочке) + // дословное замечание наставника на NO-GO. Best-effort, fail-quiet. try { const fp = String((event.tool_input && event.tool_input.file_path) || ''); const stage = SPEC_PATH_RE.test(fp) ? 'spec' : (PLAN_PATH_RE.test(fp) ? 'plan' : null); if (stage) { const rm = await import('./round-memory-record.mjs'); rm.recordArtifact(res.taskId, stage, planContent); if (blocked) rm.recordSideObjection(res.taskId, stage, 'mentor', formatMentorObjection(res)); } } catch { /* fail-quiet */ } // owner-seal (фикс 2026-06-21): вычисляем открыт ли терминальный owner-seal-грант владельца // на хеш тела (как у судьи) и передаём в решение — наставник чтит owner-seal и пропускает к судье. const ownerSealOpen = ownerSealOpenForEvent({ event, sessionId: sess, grantsLoader: loadTerminalGrants, consumedLoader: loadConsumed, }); const decision = decideMentorObjection({ res, planContent, n, ownerSealOpen }); // Способ B (Task 2.2): наставник НЕ печатает. На GO — записывает подписанное одобрение // (mentor-GO, binding plan_hash); печать сделает судья (хук ПОСЛЕ) при валидном mentor-GO. if (decision.recordMentorGo && res.planHash) { try { persistMentorGo({ record: buildMentorGo({ planHash: res.planHash, key: resolveReceiptKey() }), sessionId: sess, runtimeDir: dir, }); } catch { /* best-effort: нет записи → судья просто не запечатает (fail-safe) */ } } else if (blocked) { // Стейл-mentor-GO fix: реальный NO-GO наставника СТИРАЕТ прежнее «да» — иначе судья // находит устаревшее одобрение (тот же plan_hash при идентичных steps) и судит/печатает // план, который наставник завернул. degraded (wired:false) не трогаем (verdict неизвестен). try { clearMentorGo({ sessionId: sess, runtimeDir: dir }); } catch { /* best-effort */ } } if (decision.block) { exitDecision(decision); // exit 2 со stderr-сообщением (замечание/degraded) return; } } } catch { /* производитель никогда не блокирует */ } exitDecision({ block: false }); } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();