Files
brain/tools/enforce-mentor-on-plan-write.mjs
T
Дмитрий b978738be6 fix: NO-GO наставника стирает прежнее «да» (стейл-mentor-GO)
Судья мог судить/печатать план, который наставник завернул: mentor-GO привязан к plan_hash =
planId(steps) (только шаги), пишется ТОЛЬКО на GO и НЕ стирался на NO-GO. При идентичных steps
(менялся лишь текст плана) старое «да» переживало смену содержания — судья находил устаревшее
одобрение (mentor-go-store::mentorGoValidFor по plan_hash) и проходил mentorApproved-гейт несмотря
на свежий NO-GO наставника. Вскрыто живым прогоном (план опечатался при mentor NO-GO + judge GO).
Фикс: clearMentorGo стирает запись; enforce-mentor-on-plan-write на реальном NO-GO (blocked) её
зовёт (degraded не трогаем — verdict неизвестен). Инвариант: «да» наставника живёт ⟺ последний
проход одобрил. Свод 4376 зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:00:24 +03:00

319 lines
23 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, ownerSealActionForContent } 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, 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';
/**
* Волна 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.
*/
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 };
}
// 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,
} = {}) {
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,
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 }),
});
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 */ }
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) */ }
} 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();