#!/usr/bin/env node /** * on-plan-write (W3, A0 — нах.F5/C-1) — оркестратор записи плана: при (пере)записи * плана присваивает persisted first-plan-anchor task-id (✅O17), производит mentor-вердикт * (runMentorVerdict, binding plan_hash нах.F4) и best-effort пишет журнал переговоров * (SE10: сбой журнала НЕ крашит круг). Чистое ядро — I/O/llmCall/имплы инъектируются; * РЕГИСТРАЦИЯ как PostToolUse-хука на запись плана — шаг владельца (вне scope). * Контракты инъекций — план C2 W7 (planHash ОБЯЗАТЕЛЕН для freeze-gate F-C4; * journalKey — ключ подписи головы цепи). */ import { planId } from './plan-lock.mjs'; import { deriveTaskId } from './router-task-id.mjs'; import { runMentorVerdict, runMentorSpecVerdict } from './mentor-verdict.mjs'; import { appendNegotiation, roundCount } from './mentor-journal.mjs'; import { renderSkillContext } from './mentor-seam.mjs'; /** * Устойчивый маппинг результата classify() в список рекомендованных скилов. LLM-схема даёт * recommended_chain; prefilter/regex-выход несёт поле node (или recommended_node). 'direct' = * «скил не нужен» → пустой список. Битый/пустой вход → пустой список (не теряем молча). */ export function recommendedChainOf(c) { if (!c || typeof c !== 'object') return []; if (Array.isArray(c.recommended_chain) && c.recommended_chain.length) return [...c.recommended_chain]; const node = c.recommended_node ?? c.node ?? null; return node && node !== 'direct' ? [node] : []; } /** * @returns {{taskId, taskIdPersisted, ok, wired, verdict, reason?, journal, journalOk}} * verdict.plan_hash === planId(planSteps) (нах.F4) — caller отдаёт его freeze-gate. * roundMemory (SP2c-2) — память кругов M-side, протягивается до построителя вердикта. */ export async function onPlanWrite({ planSteps = [], existingTaskId = null, persistTaskIdImpl = null, llmCall, runMentorVerdictImpl = runMentorVerdict, appendNegotiationImpl = appendNegotiation, journalEntries = [], journalKey = null, nowMs = null, verifiedContext = [], negotiationLog = [], graphSection = null, classifyImpl = null, registry = null, declaredSkills = [], planGoal = '', roundMemory = {}, } = {}) { const planHash = planId(planSteps); // ✅O17: существующий task-id побеждает (re-issue не сбрасывает); первый план — якорь. const taskId = deriveTaskId({ existingTaskId, firstPlanHash: planHash }); let taskIdPersisted = false; if (taskId && typeof persistTaskIdImpl === 'function') { try { persistTaskIdImpl(taskId); taskIdPersisted = true; } catch { taskIdPersisted = false; } } // Мерж роутер↔наставник: зовём classify() как функцию (мозг роутера цел). Сбой/недоступен → // recommendedChain=null → наставник судит план БЕЗ скил-сверки (fail-safe §5, не ложный NO-GO). let recommendedChain = null; let routerClassification = null; // видимость: сырой результат classify наружу для снимка/баннера if (typeof classifyImpl === 'function') { try { const c = await classifyImpl(planGoal, registry); routerClassification = c ?? null; recommendedChain = recommendedChainOf(c); } catch { recommendedChain = null; routerClassification = { unavailable: true }; } } const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain, registry }); // Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд). const r = await runMentorVerdictImpl({ plan: { steps: planSteps }, planHash, verifiedContext, negotiationLog, graphSection, skillContext, roundMemory, llmCall, }); // SE10 (A4): журнал — best-effort; throw ловится, круг не падает. Обоснование непустое // всегда (F-C2/ДР-6): из вердикта либо из reason сбоя. let journal = null; let journalOk = false; try { const round = roundCount(journalEntries, taskId) + 1; journal = appendNegotiationImpl(journalEntries, { taskId, round, side: 'mentor', utterance: (r.verdict && r.verdict.recommendation) || 'вердикт не произведён', justification: (r.verdict && r.verdict.reasoning) || r.reason || 'сбой производителя вердикта', }, { key: journalKey, nowMs }); journalOk = true; } catch { journal = null; journalOk = false; } return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk, routerClassification }; } /** * Фаза 3 (отдельный spec-путь, Р6) — оркестратор записи СПЕКИ. Зеркало onPlanWrite, но * вердикт по спеке (runMentorSpecVerdict, binding specHash — хеш артефакта спеки). task-id * анкорится specHash (спека первая в стэке спека+план → план переиспользует тот же task-id). * Журнал переговоров — best-effort (SE10). I/O/llmCall инъектируются. roundMemory (SP2c-2) * — память кругов M-side спеки, протягивается до построителя вердикта. */ export async function onSpecWrite({ specContent = '', specHash = null, existingTaskId = null, persistTaskIdImpl = null, llmCall, runMentorSpecVerdictImpl = runMentorSpecVerdict, appendNegotiationImpl = appendNegotiation, journalEntries = [], journalKey = null, nowMs = null, verifiedContext = [], negotiationLog = [], graphSection = null, roundMemory = {}, } = {}) { // ✅O17: существующий task-id побеждает; спека анкорит стэк (спека+план) по specHash. const taskId = deriveTaskId({ existingTaskId, firstPlanHash: specHash }); let taskIdPersisted = false; if (taskId && typeof persistTaskIdImpl === 'function') { try { persistTaskIdImpl(taskId); taskIdPersisted = true; } catch { taskIdPersisted = false; } } const r = await runMentorSpecVerdictImpl({ specContent, specHash, verifiedContext, negotiationLog, graphSection, roundMemory, llmCall }); let journal = null; let journalOk = false; try { const round = roundCount(journalEntries, taskId) + 1; journal = appendNegotiationImpl(journalEntries, { taskId, round, side: 'mentor', utterance: (r.verdict && r.verdict.recommendation) || 'вердикт не произведён', justification: (r.verdict && r.verdict.reasoning) || r.reason || 'сбой производителя вердикта', }, { key: journalKey, nowMs }); journalOk = true; } catch { journal = null; journalOk = false; } return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk }; }