From f3ac36bef120f2a56067307dc2aa2d92da907dfe 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: Sun, 14 Jun 2026 06:11:11 +0300 Subject: [PATCH] =?UTF-8?q?revert(wall):=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=20Post-advance=20=E2=80=94=20PostToolUse=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BD=D0=B0=20=D1=83=D0=BF=D0=B0=D0=B2=D1=88=D0=B5?= =?UTF-8?q?=D0=BC=20Bash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-смоук: PostToolUse не запускается на exit≠0 → Post не двигает указатель на RED-шагах. Код возвращён к Pre-advance (3928 GREEN). Спека/план помечены ОТВЕРГНУТО. Настоящий фикс desync = перестановка skill-discipline перед supreme-gate. Co-Authored-By: Claude Opus 4.8 --- cspell-words.txt | 2 + ...26-06-14-supreme-gate-post-advance-plan.md | 6 ++ ...-06-14-supreme-gate-post-advance-design.md | 10 +- tools/enforce-supreme-gate.mjs | 93 ++++++------------- tools/enforce-supreme-gate.test.mjs | 93 ------------------- 5 files changed, 43 insertions(+), 161 deletions(-) diff --git a/cspell-words.txt b/cspell-words.txt index 59e75f8d..fd664049 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2102,3 +2102,5 @@ econnreset исполнённый Ptr брифа +агностичен +desync diff --git a/docs/superpowers/plans/2026-06-14-supreme-gate-post-advance-plan.md b/docs/superpowers/plans/2026-06-14-supreme-gate-post-advance-plan.md index 2b2f8334..d5dd5d23 100644 --- a/docs/superpowers/plans/2026-06-14-supreme-gate-post-advance-plan.md +++ b/docs/superpowers/plans/2026-06-14-supreme-gate-post-advance-plan.md @@ -1,5 +1,11 @@ # supreme-gate PostToolUse-advance — Implementation Plan +⛔ **ОТВЕРГНУТО (2026-06-14).** Подход Post-advance реализован и откатан: live-смоук +показал, что PostToolUse-хуки не срабатывают на упавшем (exit≠0) инструменте → RED-шаги +TDD не продвигаются. Корректный фикс — Pre-advance + перестановка `enforce-domain-skill-discipline` +ПЕРЕД `enforce-supreme-gate`. Детали — в спеке `2026-06-14-supreme-gate-post-advance-design.md` +(статус ОТВЕРГНУТ). План сохранён как запись урока. + > **For agentic workers:** REQUIRED SUB-SKILL: исполнять inline через superpowers:executing-plans > (деликатное ядро стены — НЕ субагенты, per Pravila §15 / брифа эпика). Steps — чекбоксы. diff --git a/docs/superpowers/specs/2026-06-14-supreme-gate-post-advance-design.md b/docs/superpowers/specs/2026-06-14-supreme-gate-post-advance-design.md index e06cea03..353b3942 100644 --- a/docs/superpowers/specs/2026-06-14-supreme-gate-post-advance-design.md +++ b/docs/superpowers/specs/2026-06-14-supreme-gate-post-advance-design.md @@ -2,7 +2,15 @@ **Дата:** 2026-06-14 **Эпик:** роутер-наставник. Машина 2 (верховная стена). -**Статус:** дизайн одобрен (подход A), готов к writing-plans. +**Статус:** ⛔ ОТВЕРГНУТ (2026-06-14, по итогам live-смоука). Подход Post-advance +нереализуем: **PostToolUse-хуки не запускаются на упавшем (exit≠0) инструменте**, +поэтому Post-такт не двигает указатель на RED-шагах (намеренно падающий тест в TDD). +Доказано журналом сессии (Pre записал намерение Bash-шага, указатель остался на месте, +тогда как успешный Write-шаг продвигался). **Корректный фикс** — оставить исходный +Pre-advance (exit-агностичен) и переставить `enforce-domain-skill-discipline` ПЕРЕД +`enforce-supreme-gate` в PreToolUse (блокирующий навык-судья срабатывает первым → +supreme-gate не успевает сдвинуть; тот же desync закрыт, RED-шаги целы; остаётся +редкий residual user-deny). Код фикса откатан. Спека сохранена как запись урока. ## Проблема diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index ebf8b365..7c75da31 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -336,26 +336,38 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k } /** - * PreToolUse-такт (фикс рассинхрона 2026-06-14): ТОЛЬКО ворота. decideMode → block при - * несовпадении; escape/finishPlan — здесь (они от исполнения не зависят); на allow+advance — - * журнал-намерение (Δ3, «нет записи → нет действия»), но указатель НЕ двигается (сдвиг — в - * runGatePost после подтверждённого исполнения). + * Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг. + * journal/saveStep инъектируются (в main — реальные Node fs). */ -export function runGatePre({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { +export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { const toolUse = { name: event.tool_name, input: event.tool_input }; const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); + // FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал + // (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие + // журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова + // закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера + // «action-without-record (обход стены)» для легитимного escape. if (r.mode === 'escape') { if (typeof journal === 'function') { try { journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ } } return { block: false, message: r.reason }; } + // Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать + // (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем. if (r.finishPlan) { if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } return { block: false, message: r.reason }; } + // W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается + // в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме + // гейта-1 — поведенческая сборка контроллера, owner-activation). const withWarn = (msg) => (r.warn ? `${msg} ⚠ ${r.warnReason}` : msg); if (r.decision === 'allow' && r.advance === true) { + // Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения — + // честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ + // разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает + // PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок). let recorded; try { recorded = journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null }) !== false; @@ -363,49 +375,15 @@ export function runGatePre({ event, frozenPlan, frozenArtifact, stepPtr, key, ve if (!recorded) { return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' }; } - // Сдвиг указателя НЕ здесь — runGatePost после подтверждённого исполнения (фикс рассинхрона). - } - return { block: r.decision === 'block', message: withWarn(r.reason) }; -} - -/** - * Совместимость/прямой вызов: Pre-такт, затем (если не блок) Post-такт — эквивалент - * прежнего одно-проходного поведения для ПРЯМОГО вызова (тесты/инструменты). В реальной - * хук-цепочке main() зовёт runGatePre (PreToolUse) и runGatePost (PostToolUse) РАЗДЕЛЬНО, - * иначе поздний блок / user-deny рассинхронят указатель (спека 2026-06-14). journal/saveStep/ - * removeFrozenPlan инъектируются (в main — реальные Node fs); args общий для обоих тактов. - */ -export function runGate(args) { - const pre = runGatePre(args); - if (pre.block) return pre; - runGatePost(args); - return pre; -} - -/** Различение такта по событию harness: PostToolUse → сдвиг указателя; иначе → ворота. */ -export function isPostEvent(event) { - return !!event && event.hook_event_name === 'PostToolUse'; -} - -/** - * PostToolUse-такт (фикс рассинхрона 2026-06-14): сдвиг указателя ТОЛЬКО после - * подтверждённого исполнения инструмента. Пере-выводит decideMode по ТЕКУЩЕМУ - * (не сдвинутому) stepPtr; на allow+advance — saveStep(advanceTo) (+ removeFrozenPlan - * при planComplete). НИКОГДА не блокирует (инструмент уже исполнился). - * seed/observe/escape/finishPlan/несовпадение → не двигает. Если инструмент был - * заблокирован поздним хуком / user-deny — PostToolUse не сработал, сдвига нет. - */ -export function runGatePost({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { - const toolUse = { name: event.tool_name, input: event.tool_input }; - const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); - if (r.decision === 'allow' && r.advance === true) { saveStep(r.advanceTo); + // Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает + // печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан», + // не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow. if (r.planComplete && typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } - return { advanced: true, planComplete: !!r.planComplete }; } - return { advanced: false, planComplete: false }; + return { block: r.decision === 'block', message: withWarn(r.reason) }; } /** @@ -437,35 +415,16 @@ async function main() { const stepPath = stepStatePath(runtimeDir, sess); // N3-shared guard формы sessionId let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {} const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, key ? (s) => verifyStepState(s, key) : null); // R-27 привязка + R-19 подпись - const common = { event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed }; - if (isPostEvent(event)) { - // PostToolUse-такт (фикс рассинхрона): сдвиг указателя по ПОДТВЕРЖДЁННОМУ исполнению. - // Никогда не блокирует (инструмент уже исполнился). Если действие было заблокировано - // поздним хуком / user-deny — PostToolUse не сработал → сдвига нет. - runGatePost({ - ...common, - saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано - removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение - }); - exitDecision({ block: false }); - return; - } - // PreToolUse-такт: ТОЛЬКО ворота + журнал-намерение (Δ3). Указатель НЕ двигается здесь. - const r = runGatePre({ - ...common, + const r = runGate({ + event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed, journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), - removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // finishPlan (досрочно владельцем) + saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано + removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение }); if (r.block) logGuardBlock(event, 'М2 Стена', r.message); exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined }); } catch { - // Post-такт: fail-safe — никогда не блокирует уже исполнённое (но fail-loud WARN). - if (isPostEvent(event)) { - try { process.stderr.write('[supreme-gate] PostToolUse: внутренняя ошибка — сдвиг указателя пропущен (fail-safe)\n'); } catch { /* ignore */ } - exitDecision({ block: false }); - return; - } - // Pre-такт panic (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён. + // Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён. const p = panicEscapeDecision(event, escapeGrants, escapeConsumed); exitDecision({ block: p.block, message: p.block ? '[supreme-gate] внутренняя ошибка — fail-CLOSED' : undefined }); } diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index f6f38169..2aadf063 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -781,96 +781,3 @@ describe('Фаза 5 — чистое завершение плана (стен expect(r.block).toBe(false); }); }); - -// 2026-06-14 — фикс рассинхрона указателя: сдвиг переехал на PostToolUse (спека 2026-06-14). -import { runGatePost } from './enforce-supreme-gate.mjs'; - -describe('runGatePost (сдвиг по подтверждённому исполнению)', () => { - const baseArgs = (over) => ({ - event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, - frozenPlan: { ...PLAN, judge_mode: 'live-block' }, - frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, - stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, - normalize: (p) => p.toLowerCase(), saveStep: () => {}, ...over, - }); - - it('allow+advance → saveStep(advanceTo) вызван, advanced:true, не блокирует', () => { - let saved = null; - const r = runGatePost(baseArgs({ saveStep: (n) => { saved = n; } })); - expect(saved).toBe(1); - expect(r.advanced).toBe(true); - expect(r.block).toBeUndefined(); - }); - - it('несовпадение шага → saveStep НЕ вызван, advanced:false', () => { - let saved = 'untouched'; - const r = runGatePost(baseArgs({ - event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } }, - saveStep: (n) => { saved = n; }, - })); - expect(saved).toBe('untouched'); - expect(r.advanced).toBe(false); - }); - - it('seed (Skill brainstorming) → не двигает (advance не true)', () => { - let saved = 'untouched'; - const r = runGatePost(baseArgs({ - event: { tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } }, - saveStep: (n) => { saved = n; }, - })); - expect(saved).toBe('untouched'); - expect(r.advanced).toBe(false); - }); -}); - -import { runGatePre } from './enforce-supreme-gate.mjs'; - -describe('runGatePre (ворота + журнал-намерение, БЕЗ сдвига)', () => { - const baseArgs = (over) => ({ - event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, - frozenPlan: { ...PLAN, judge_mode: 'live-block' }, - frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, - stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, - normalize: (p) => p.toLowerCase(), journal: () => true, ...over, - }); - - it('allow+advance → журналирует намерение, block:false (указатель НЕ двигает — нет saveStep)', () => { - const journaled = []; - const r = runGatePre(baseArgs({ journal: (e) => { journaled.push(e); return true; } })); - expect(r.block).toBe(false); - expect(journaled).toHaveLength(1); - expect(journaled[0].step).toBe(1); - }); - - it('несовпадение шага → block, не журналирует', () => { - const journaled = []; - const r = runGatePre(baseArgs({ - event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } }, - journal: (e) => { journaled.push(e); return true; }, - })); - expect(r.block).toBe(true); - expect(journaled).toHaveLength(0); - }); - - it('Δ3: journal вернул false → block', () => { - const r = runGatePre(baseArgs({ journal: () => false })); - expect(r.block).toBe(true); - expect(r.message).toMatch(/пред-запис|нет записи/i); - }); - - it('Δ3: journal бросил → block', () => { - const r = runGatePre(baseArgs({ journal: () => { throw new Error('io'); } })); - expect(r.block).toBe(true); - }); -}); - -import { isPostEvent } from './enforce-supreme-gate.mjs'; - -describe('isPostEvent (различение такта по событию harness)', () => { - it('PostToolUse → true', () => { expect(isPostEvent({ hook_event_name: 'PostToolUse' })).toBe(true); }); - it('PreToolUse → false', () => { expect(isPostEvent({ hook_event_name: 'PreToolUse' })).toBe(false); }); - it('нет поля / null → false', () => { - expect(isPostEvent({})).toBe(false); - expect(isPostEvent(null)).toBe(false); - }); -});