From fd6046fa92a9808f1afb3e090290bad48dbb60a9 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: Tue, 16 Jun 2026 17:29:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20round-memory=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D0=BA=D1=80=D1=83=D0=B3=D0=BE=D0=B2=20per?= =?UTF-8?q?-=D1=81=D1=82=D0=B0=D0=B4=D0=B8=D1=8F=20spec/plan=20SP2c-3a=20?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tools/enforce-judge-gate.mjs | 8 +++++--- tools/enforce-judge-gate.test.mjs | 8 ++++++++ tools/mentor-nogo-counter.mjs | 10 ++++++---- tools/mentor-nogo-counter.test.mjs | 8 ++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index 098b62b..21c1ae7 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -437,10 +437,12 @@ const JUDGE_ESCALATE_AFTER = 3; * blocked=true → +1; blocked=false (allow) → сброс 0. Возвращает новый счёт. * fsImpl/dir инъектируемы для тестов. Best-effort — ошибка I/O не ломает судью. */ -export function bumpJudgeNoGo({ taskId, sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { - // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sessionId — fallback). +export function bumpJudgeNoGo({ taskId, sessionId, stage, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { + // SP2c-3: счётчик на КАЖДУЮ стадию ОТДЕЛЬНО — ключ (task-id + stage), по дизайну §0/§6. + // stage отсутствует → 'all' (backward-compat). sessionId — fallback к task-id. const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); - const path = join(dir, `judge-nogo-${safe}.json`); + const stageKey = String(stage || 'all').replace(/[^a-zA-Z0-9_-]/g, '_'); + const path = join(dir, `judge-nogo-${safe}-${stageKey}.json`); let count = 0; try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; } const next = blocked ? count + 1 : 0; diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index 9929b46..e95138b 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -18,6 +18,14 @@ describe('bumpJudgeNoGo per task-id (Фаза 4 — счётчик на стэк expect(bumpJudgeNoGo({ taskId: 'task:B', blocked: false, fsImpl, dir })).toBe(0); expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(3); }); + it('SP2c-3: spec и plan одной задачи → независимые потолки (per-стадия §0/§6)', () => { + const fsImpl = mem(); const dir = '/r'; + expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'plan', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(2); + expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'plan', blocked: false, fsImpl, dir })).toBe(0); + expect(bumpJudgeNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(3); + }); }); describe('bindingHashForJudge (Фаза 3 — какой хеш судья сверяет с mentor-GO)', () => { diff --git a/tools/mentor-nogo-counter.mjs b/tools/mentor-nogo-counter.mjs index e05baed..b0b5ca4 100644 --- a/tools/mentor-nogo-counter.mjs +++ b/tools/mentor-nogo-counter.mjs @@ -9,11 +9,13 @@ import { runtimeDir } from './enforce-hook-helpers.mjs'; export const MENTOR_ESCALATE_AFTER = 3; -export function bumpMentorNoGo({ taskId, sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { - // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sessionId — fallback - // backward-compat). Две правки в одной сессии → независимые счётчики. +export function bumpMentorNoGo({ taskId, sessionId, stage, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { + // SP2c-3: счётчик на КАЖДУЮ стадию ОТДЕЛЬНО — ключ (task-id + stage), по дизайну §0/§6 + // (спека и план — два прогона цикла, у каждого свой потолок ≤3). stage отсутствует → 'all' + // (backward-compat). sessionId — fallback к task-id. const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); - const path = join(dir, `mentor-nogo-${safe}.json`); + const stageKey = String(stage || 'all').replace(/[^a-zA-Z0-9_-]/g, '_'); + const path = join(dir, `mentor-nogo-${safe}-${stageKey}.json`); let count = 0; try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; } const next = blocked ? count + 1 : 0; diff --git a/tools/mentor-nogo-counter.test.mjs b/tools/mentor-nogo-counter.test.mjs index 130815f..c07cbee 100644 --- a/tools/mentor-nogo-counter.test.mjs +++ b/tools/mentor-nogo-counter.test.mjs @@ -25,6 +25,14 @@ describe('bumpMentorNoGo', () => { expect(bumpMentorNoGo({ taskId: 'task:B', blocked: false, fsImpl, dir })).toBe(0); // сброс только B expect(bumpMentorNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(3); // A не тронут }); + it('SP2c-3: spec и plan одной задачи → независимые потолки (per-стадия §0/§6)', () => { + const fsImpl = mem(); const dir = '/r'; + expect(bumpMentorNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpMentorNoGo({ taskId: 'task:A', stage: 'plan', blocked: true, fsImpl, dir })).toBe(1); // план независим от спеки + expect(bumpMentorNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(2); + expect(bumpMentorNoGo({ taskId: 'task:A', stage: 'plan', blocked: false, fsImpl, dir })).toBe(0); // сброс только plan + expect(bumpMentorNoGo({ taskId: 'task:A', stage: 'spec', blocked: true, fsImpl, dir })).toBe(3); // spec не тронут + }); it('порог эскалации = 3', () => { expect(MENTOR_ESCALATE_AFTER).toBe(3); }); it('небезопасный sessionId санитизируется (не падает)', () => { const fsImpl = mem();