diff --git a/tools/enforce-mentor-on-plan-write.mjs b/tools/enforce-mentor-on-plan-write.mjs index 29ed1a6..60032f2 100644 --- a/tools/enforce-mentor-on-plan-write.mjs +++ b/tools/enforce-mentor-on-plan-write.mjs @@ -32,7 +32,7 @@ import { parseNegotiationSection, arbitrationRequested } from './negotiation-sec 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 { 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 + замечание судьи @@ -298,6 +298,11 @@ async function main() { 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) diff --git a/tools/mentor-go-clear.test.mjs b/tools/mentor-go-clear.test.mjs new file mode 100644 index 0000000..7937028 --- /dev/null +++ b/tools/mentor-go-clear.test.mjs @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { buildMentorGo, persistMentorGo, loadMentorGo, clearMentorGo } from './mentor-go-store.mjs'; + +function memFs() { + const s = new Map(); + return { s, + writeFileSync: (p, d) => s.set(String(p), d), + renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); }, + readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); }, + unlinkSync: (p) => { if (!s.has(String(p))) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } s.delete(String(p)); }, + }; +} +const KEY = 'mgk'; const DIR = '/rt'; const SESS = 's1'; + +describe('clearMentorGo — реальный NO-GO стирает прежнее «да» наставника (стейл-fix)', () => { + it('после persist + clear загрузка возвращает null', () => { + const fs = memFs(); + persistMentorGo({ record: buildMentorGo({ planHash: 'H', key: KEY }), sessionId: SESS, runtimeDir: DIR, fsImpl: fs }); + expect(loadMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).not.toBe(null); + clearMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs }); + expect(loadMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).toBe(null); + }); + it('clear на отсутствующем файле не бросает (no-op)', () => { + const fs = memFs(); + expect(() => clearMentorGo({ sessionId: SESS, runtimeDir: DIR, fsImpl: fs })).not.toThrow(); + }); +}); diff --git a/tools/mentor-go-store.mjs b/tools/mentor-go-store.mjs index 74c4184..cf1bc78 100644 --- a/tools/mentor-go-store.mjs +++ b/tools/mentor-go-store.mjs @@ -48,3 +48,11 @@ 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; } +}