1289e68524
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
19 KiB
JavaScript
283 lines
19 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, 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';
|
||
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,
|
||
});
|
||
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. 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);
|
||
// SP2c-2: память кругов M-side спеки (свои замечания + M-доводы + diff + замечание судьи).
|
||
const roundMemoryS = roundMemoryImpl ? roundMemoryImpl({ stage: 'spec', content, taskId: taskIdForPromptS }) : {};
|
||
const rs = await onSpecWrite({
|
||
specContent: content,
|
||
specHash,
|
||
existingTaskId: loadTaskIdImpl(),
|
||
persistTaskIdImpl,
|
||
llmCall,
|
||
journalEntries: journalS.entries,
|
||
journalKey,
|
||
nowMs,
|
||
verifiedContext: verifiedContextS,
|
||
negotiationLog: negotiationLogS,
|
||
graphSection: graphSectionS,
|
||
roundMemory: roundMemoryS,
|
||
});
|
||
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; }
|
||
// SP2c-2: память кругов M-side плана (свои замечания + M-доводы + diff + замечание судьи).
|
||
const roundMemoryP = roundMemoryImpl ? roundMemoryImpl({ stage: 'plan', content, taskId: taskIdForPrompt }) : {};
|
||
const r = await onPlanWrite({
|
||
planSteps: steps,
|
||
existingTaskId: loadTaskIdImpl(),
|
||
persistTaskIdImpl,
|
||
llmCall,
|
||
journalEntries: journal0.entries,
|
||
journalKey,
|
||
nowMs,
|
||
verifiedContext,
|
||
negotiationLog,
|
||
graphSection,
|
||
classifyImpl,
|
||
registry,
|
||
declaredSkills,
|
||
planGoal,
|
||
roundMemory: roundMemoryP,
|
||
});
|
||
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; } },
|
||
// 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 {
|
||
pushVerdict(sess, {
|
||
outcome: classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision }),
|
||
gate: 'mentor',
|
||
round: null,
|
||
version: null,
|
||
reason: (res.verdict && res.verdict.recommendation) || res.reason || '',
|
||
});
|
||
} 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: счётчик на СТЭК (спека+план) одной задачи — ключ 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) ?? '');
|
||
// 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) */ }
|
||
}
|
||
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();
|