140 lines
8.3 KiB
JavaScript
140 lines
8.3 KiB
JavaScript
#!/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();
|