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

140 lines
8.3 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 } from './enforce-judge-gate.mjs';
import { sealablePlan } from './seal-orchestration.mjs';
import { planId } from './plan-lock.mjs';
import { onPlanWrite } from './on-plan-write.mjs';
import { parseVerifiedContext } from './plan-verified-context.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 } 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 { parseNegotiationSection } from './negotiation-section.mjs';
import { bumpMentorNoGo, MENTOR_ESCALATE_AFTER } from './mentor-nogo-counter.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');
}
/** Адаптер llmCall (паттерн судьи [enforce-judge-gate.mjs:167-177]): throw НЕ глотаем —
* его ловит runMentorVerdict → wired:false (SE-R6-6, не суд). */
export function buildLlmCall({ apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) {
return async ({ buildPrompt }) => transport(buildPrompt(), { apiKey, model });
}
/**
* Чистый производитель: 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,
} = {}) {
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) || '');
if (tool !== 'Write' || !PLAN_PATH_RE.test(filePath)) return { ran: false, reason: 'не запись плана' };
const content = String((event && event.tool_input && event.tool_input.content) ?? '');
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);
const r = await onPlanWrite({
planSteps: steps,
existingTaskId: loadTaskIdImpl(),
persistTaskIdImpl,
llmCall,
journalEntries: journal0.entries,
journalKey,
nowMs,
verifiedContext,
negotiationLog,
graphSection,
});
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 };
}
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,
});
if (res && res.ran) {
const blocked = res.wired === true && res.ok !== true;
const n = bumpMentorNoGo({ sessionId: sess, blocked });
if (blocked) {
const planContent = String((event.tool_input && event.tool_input.content) ?? '');
const msg = n >= MENTOR_ESCALATE_AFTER ? buildMentorArbitrationMessage(res, planContent, n) : formatMentorObjection(res);
if (msg) console.error(msg);
}
}
} catch { /* производитель никогда не блокирует */ }
exitDecision({ block: false });
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();