From cdcaf610a0ff23b94ba0d31b7c6192dd36140fa2 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 14:49:33 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=C2=A73.4=20=D0=B4=D0=B5=D1=81=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=20=E2=80=94=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=9B=D0=95=D0=9D=D0=98=D0=92=D0=9E=20(criterion-gate=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B8=D1=82=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=83=D1=88=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Баг: на ПОСЛЕДНЕМ шаге плана supreme-gate (PreToolUse) снимал печать синхронно ДО того, как со-хук criterion-gate (PreToolUse, в settings.json ПОЗЖЕ) успевал проверить пуш → criterion видел «нет плана» и ложно блокировал код-пуш. Фикс (правка только enforce-supreme-gate.mjs runGate): - на planComplete печать БОЛЬШЕ НЕ снимается синхронно (указатель за конец + метка петли E-S1 остаются) → план жив для co-хуков в этом же tool-call; - печать снимается ЛЕНИВО на СЛЕДУЮЩЕМ действии (ветка в начале runGate: если committed-указатель за последним листом и steps — непустой массив → removeFrozenPlan + разговорный режим). Гард steps на массив: минимальные/legacy-планы без steps не считаются «исчерпанными». Строго лучше прежнего: чинит ложный блок код-пуша последним шагом (зелёные критерии), случай реального провала критерия не ухудшен. Спека §3.4. +2 теста (ленивое снятие + репро «печать не снята синхронно»), свод 4320 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 --- tools/enforce-supreme-gate.mjs | 22 ++++++++++++++++++---- tools/enforce-supreme-gate.test.mjs | 24 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 6643eb7..caa9671 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -438,6 +438,20 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { const toolUse = { name: event.tool_name, input: event.tool_input }; const incomingAction = actionOf(toolUse); + // §3.4 (десинк fix, ленивое завершение Фазы 5): план был доведён до конца на ПРОШЛОМ действии + // (committed-указатель за последним листом), но печать НЕ снималась синхронно на последнем шаге — + // чтобы со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидел валидный план на код-пуше и не дал + // ложный блок. Снимаем печать ЛЕНИВО ЗДЕСЬ → текущее действие идёт в разговорном режиме (frozenPlan + // обнулён). Снятие best-effort (сбой не ломает). Указатель за концом резолвится в null-лист. + if (frozenPlan && Array.isArray(frozenPlan.steps) && frozenPlan.steps.length > 0 && (!verifyImpl || verifyImpl(frozenPlan, key))) { + let exhausted = false; + try { exhausted = treeLeafAt(frozenPlan.steps, stepPtr) == null; } catch { exhausted = false; } + if (exhausted) { + if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } + frozenPlan = null; // далее — разговорный режим (печать снята) + tentativeToPtr = null; + } + } // F-J: двухтактный сдвиг. Сверить открытую ПРЕДВАРИТЕЛЬНУЮ пометку с входным действием ДО // решения: commit (= шаг по toPtr → прошлый исполнился) / discard (= повтор шага → прошлый был // заблокирован, не исполнился) / hold / none. Решение принимается по эффективному указателю. @@ -477,12 +491,12 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeT return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' }; } if (r.planComplete) { - // Последний шаг: ранний сдвиг + снятие печати немедленно (mid-plan-клина нет — следующего - // шага не существует; блок последнего шага со-хуком отдаёт участника в разговорный режим, - // не в тупик). Best-effort: сбой снятия НЕ ломает allow. + // Последний шаг: ранний сдвиг указателя за конец + метка петли (E-S1). Печать НЕ снимаем + // здесь (§3.4): синхронное снятие на последнем шаге опережало co-хук criterion-gate (он видел + // «нет плана» → ложный блок код-пуша). Печать снимается ЛЕНИВО на СЛЕДУЮЩЕМ действии (ветка + // ленивого завершения в начале runGate, указатель за концом) → план жив для co-хуков сейчас. saveStep(r.advanceTo, null); if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* E-S1: сбой метки не ломает завершение */ } } - if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } } else { // F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Committed-указатель остаётся на // текущем шаге; toPtr подтвердится следующим действием (commit) либо сбросится (discard). diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 5089ce6..3b6b26a 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -865,16 +865,32 @@ describe('Фаза 5 — чистое завершение плана (стен expect(r.decision).toBe('allow'); expect(r.planComplete).toBe(false); }); - it('runGate: последний шаг выполнен → removeFrozenPlan вызван (печать снята → разговорный)', () => { - let removed = 0; + // §3.4 (десинк fix): на ПОСЛЕДНЕМ шаге печать НЕ снимается синхронно в этом же вызове — + // иначе со-хук criterion-gate (PreToolUse ПОСЛЕ supreme) увидит «нет плана» и ложно заблокирует + // код-пуш. Печать снимается ЛЕНИВО на следующем действии (указатель за концом). Указатель сдвигается. + it('runGate: последний шаг выполнен → removeFrozenPlan НЕ вызван синхронно (ленивое снятие, §3.4)', () => { + let removed = 0; const saved = []; const r = runGateCE({ event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE, verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, - journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, + journal: () => true, saveStep: (p) => saved.push(p), removeFrozenPlan: () => { removed++; }, }); expect(r.block).toBe(false); - expect(removed).toBe(1); + expect(removed).toBe(0); // НЕ снято синхронно — план жив для criterion-gate + expect(saved).toContain(1); // указатель всё равно сдвинут за конец (advanceTo=1) + }); + it('runGate: ленивое снятие — указатель уже за концом → removeFrozenPlan + разговорный (§3.4)', () => { + let removed = 0; + const r = runGateCE({ + event: { tool_name: 'Write', tool_input: { file_path: 'tools/other.mjs' } }, + frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 1, key: KCE, + verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, + journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, + }); + expect(removed).toBe(1); // план исчерпан на прошлом действии → снят лениво сейчас + expect(r.block).toBe(true); // действие вне плана → разговорный default-deny (мутатор) + expect(r.message).toMatch(/разговорн|нет замороженного плана/i); }); it('runGate: НЕ последний шаг → removeFrozenPlan НЕ вызван (печать держится)', () => { let removed = 0;