Files
brain/tools/enforce-mentor-on-plan-write.mjs
T

246 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 } from './enforce-judge-gate.mjs';
import { sealablePlan, sealableArtifact, judgedHashOf } from './seal-orchestration.mjs';
import { planId } from './plan-lock.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 } 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 } from './mentor-go-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,
});
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.
*/
export function decideMentorObjection({ res, planContent, n } = {}) {
// degraded (наставник не дозвонился, спека §9): block:true с «не смог дозвониться»,
// одобрения нет (recordMentorGo:false), это НЕ NO-GO (escalation не растёт).
if (res && res.ran && res.wired === false) {
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 };
}
const message = n >= MENTOR_ESCALATE_AFTER
? 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. 90с укладывает с большим запасом (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 инъектируются.
*/
export async function runMentorOnPlanWrite(event, {
mentorActiveImpl, llmCall, loadJournalImpl, persistJournalImpl, persistVerdictImpl,
loadTaskIdImpl, persistTaskIdImpl, journalKey, graphSectionImpl, nowMs = null,
classifyImpl = null, registryImpl = 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);
const rs = await onSpecWrite({
specContent: content,
specHash,
existingTaskId: loadTaskIdImpl(),
persistTaskIdImpl,
llmCall,
journalEntries: journalS.entries,
journalKey,
nowMs,
verifiedContext: verifiedContextS,
negotiationLog: negotiationLogS,
graphSection: graphSectionS,
});
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 r = await onPlanWrite({
planSteps: steps,
existingTaskId: loadTaskIdImpl(),
persistTaskIdImpl,
llmCall,
journalEntries: journal0.entries,
journalKey,
nowMs,
verifiedContext,
negotiationLog,
graphSection,
classifyImpl,
registry,
declaredSkills,
planGoal,
});
const planHash = planId(steps);
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 };
}
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; } },
});
if (res && res.ran) {
// Р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: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sess — fallback).
const n = bumpMentorNoGo({ taskId: res.taskId, sessionId: sess, blocked });
// Фаза 1 (Р2): на NO-GO/degraded — ПОЛНЫЙ текст доходит до контроллера через рабочий
// exit-2 канал (подтверждён Фазой 0). На 3-м NO-GO — карточка арбитража.
const planContent = String((event.tool_input && event.tool_input.content) ?? '');
const decision = decideMentorObjection({ res, planContent, n });
// Способ 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) */ }
}
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();