Files
brain/tools/mentor-go-store.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

59 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
/**
* mentor-go-store (способ B, Фаза 2) — наставник в Post записывает подписанное «я одобрил
* ЭТОТ план» (привязка к plan_hash, нах.F4). Судья (хук ПОСЛЕ наставника) читает запись и
* судит/печатает ТОЛЬКО при валидном mentor-GO; нет одобрения наставника → судья молчит
* (fail-safe). Зеркало judge-go-store, домен подписи MENTOR_GO.
*/
import fsDefault from 'node:fs';
import { assertSafeSessionId } from './action-journal.mjs';
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
const DOMAIN = RECEIPT_DOMAINS.MENTOR_GO;
function mentorGoPath(runtimeDir, sessionId) {
assertSafeSessionId(sessionId);
const sep = runtimeDir.endsWith('/') ? '' : '/';
return `${runtimeDir}${sep}mentor-go-${sessionId}.json`;
}
/** Чистая сборка подписанной записи «наставник одобрил» для плана (plan_hash — binding нах.F4). */
export function buildMentorGo({ planHash, judgeMode = null, key, nowMs = null }) {
const base = {
plan_hash: planHash ?? null,
approved: true,
at: typeof nowMs === 'number' ? nowMs : null,
};
return { ...base, sig: signPayload(base, key, DOMAIN) };
}
/** Запись валидна И принадлежит ЭТОМУ плану И подпись цела? Иначе false (fail-closed). */
export function mentorGoValidFor(record, { planHash, key } = {}) {
if (!record || typeof record !== 'object') return false;
if (record.plan_hash !== planHash) return false;
if (record.approved !== true) return false;
return verifyReceipt(record, key, DOMAIN);
}
/** Атомарная запись одобрения наставника в ~/.claude/runtime/mentor-go-<sess>.json. */
export function persistMentorGo({ record, sessionId, runtimeDir, fsImpl = fsDefault }) {
const p = mentorGoPath(runtimeDir, sessionId);
const tmp = `${p}.tmp`;
fsImpl.writeFileSync(tmp, JSON.stringify(record));
fsImpl.renameSync(tmp, p);
}
/** Загрузка одобрения наставника (нет файла → null). */
export function loadMentorGo({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(mentorGoPath(runtimeDir, sessionId), 'utf8')); }
catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/** Стереть одобрение наставника. Реальный NO-GO наставника обязан стирать прежнее «да» —
* иначе судья находит УСТАРЕВШЕЕ одобрение (тот же plan_hash при идентичных steps, т.к.
* binding = planId(steps)) и судит/печатает план, который наставник завернул. Нет файла → no-op. */
export function clearMentorGo({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { fsImpl.unlinkSync(mentorGoPath(runtimeDir, sessionId)); }
catch (e) { if (e && e.code !== 'ENOENT') throw e; }
}