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

263 lines
17 KiB
JavaScript
Raw Normal View History

#!/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);
try {
const cn = await import('./controller-negotiation.mjs');
negotiationLog.push(...cn.controllerNegotiationEntries(content));
} catch { /* best-effort */ }
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, router: r.router, round: r.round };
}
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 });
try {
const m1 = await import('./verdict-surface-store.mjs');
const det = await import('./verdict-surface-detail.mjs');
const kindObj = decision.degraded ? { kind: 'degraded', reason: res.reason || 'транспорт недоступен' }
: decision.block ? { kind: 'nogo', text: formatMentorObjection(res) }
: decision.recordMentorGo ? { kind: 'go' }
: { kind: 'skip', reason: res.reason || 'не судится' };
const routerLine = det.buildRouterSurface(res.router, { round: res.round });
const mentorLine = det.buildMentorSurface(kindObj, { round: res.round, verdict: res.verdict });
for (const line of [routerLine, mentorLine]) {
if (line) m1.appendVerdictOutcome({ sessionId: sess, line, nowMs: Date.now() });
}
} catch { /* best-effort */ }
// Способ 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();