From b978738be6aa73f4cbe6a10d2c4edd4dd42a95e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 18 Jun 2026 22:00:24 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20NO-GO=20=D0=BD=D0=B0=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=BD=D0=B8=D0=BA=D0=B0=20=D1=81=D1=82=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD=D0=B5=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=B4=D0=B0=C2=BB=20(=D1=81=D1=82=D0=B5=D0=B9=D0=BB-ment?= =?UTF-8?q?or-GO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Судья мог судить/печатать план, который наставник завернул: 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 --- tools/enforce-mentor-on-plan-write.mjs | 7 ++++++- tools/mentor-go-clear.test.mjs | 27 ++++++++++++++++++++++++++ tools/mentor-go-store.mjs | 8 ++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tools/mentor-go-clear.test.mjs 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; } +}