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;