# supreme-gate: сдвиг указателя шага на PostToolUse (робастный фикс рассинхрона)

**Дата:** 2026-06-14
**Эпик:** роутер-наставник. Машина 2 (верховная стена).
**Статус:** ⛔ ОТВЕРГНУТ (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). Код фикса откатан. Спека сохранена как запись урока.

## Проблема

`tools/enforce-supreme-gate.mjs` зарегистрирован на **PreToolUse** (matcher `'*'`).
Функция `runGate` при `decide → allow + advance` делает `saveStep(advanceTo)`
(строка ~378) **в PreToolUse**, то есть **до фактического исполнения инструмента**.

PreToolUse-хуки выполняются цепочкой; если **любой более поздний** хук вернёт
block (например, `enforce-domain-skill-discipline`, зарегистрированный после
supreme-gate), инструмент **не исполняется**, но supreme-gate уже сдвинул
указатель. То же при `user-deny` (отказ в permission-промпте — он происходит
после всех PreToolUse-хуков). Результат: указатель ушёл вперёд, а файл/команда
шага не выполнились → **рассинхрон**. План «думает», что шаг сделан, хотя его не
было; участник застревает на следующем шаге.

Подтверждено смоуком 2026-06-14: объявленный навык не вызван → skill-discipline
заблокировал Write шага 1, но supreme-gate уже продвинул указатель на шаг 2; файл
теста на диске отсутствует (`git status` пуст), а стена ждёт шаг 2.

**Корневая причина:** PreToolUse принципиально не знает, исполнится ли инструмент.
Сдвиг указателя — это подтверждение «шаг исполнен», и оно обязано происходить
**после** исполнения, то есть на **PostToolUse**. Это и есть «второй такт»,
заложенный в комментарии Δ3 самого хука («PostToolUse-реконсилер сверяет, что
произошло ровно записанное»).

## Текущая двухтактная схема (как есть)

- **PreToolUse** `enforce-supreme-gate`: ворота (block при несовпадении шага) +
  журнал-намерение (Δ3, «нет записи → нет действия») + `saveStep` (сдвиг) +
  `removeFrozenPlan` при завершении плана — всё в одном проходе `runGate`.
- **PostToolUse** `enforce-reconcile`: WARN-only сверка «исполнено = записано».
  Указатель **не трогает** (по конструкции «PostToolUse не может отменить уже
  исполненное» — это сигнал, не энфорсер).

## Решение (подход A)

`supreme-gate` обслуживает **оба** события; единственный владелец указателя
остаётся одним модулем.

- **PreToolUse** = только ворота. `decideMode` → block при несовпадении; escape /
  finishPlan / seed / observe-only / authoring — как сейчас (они не зависят от
  исполнения). На `allow + advance` — журнал-намерение (Δ3, без изменений), но
  **`saveStep` НЕ вызывается** и `removeFrozenPlan`-по-завершении **НЕ вызывается**.
- **PostToolUse** = сдвиг. Пере-вывести `decideMode` по **текущему (не сдвинутому)**
  `stepPtr`; если результат `allow + advance` (то есть исполнённый инструмент был
  реальным шагом плана) — `saveStep(advanceTo)`, и при `planComplete` —
  `removeFrozenPlan`. Журнал на PostToolUse **не пишем** (намерение записал Pre;
  сверку делает `enforce-reconcile`).
- `main()` ветвится по `event.hook_event_name` (`PreToolUse` vs `PostToolUse`).

### Отвергнутые альтернативы

- **B (отдельный хук `enforce-step-advance.mjs`):** чище по «одна ответственность»,
  но владение указателем размазано по двум файлам → риск дрейфа логики
  resolveStepPtr/signStepState/saveStep. Отвергнут ради единого владельца.
- **C (вшить сдвиг в `enforce-reconcile`):** ломает инвариант реконсилера
  «WARN-only, сигнал не энфорсер» и ставит сдвиг в зависимость от регистрации
  реконсилера. Отвергнут.

## Контракт изменения {#D1}

- `decide` и `decideMode` — **семантически не меняются** (они уже чисто вычисляют
  `advance`/`advanceTo`/`planComplete`). Это оракул решения, единый для Pre и Post.
- `runGate` расщепляется на две чистые функции с инъекцией I/O (как сейчас):
  - **`runGatePre({ event, frozenPlan, frozenArtifact, stepPtr, key, …, journal, removeFrozenPlan, escapeGrants, escapeConsumed, now })`**
    → `{ block, message }`. Гейт + escape + finishPlan + журнал-намерение. **Без `saveStep`.**
    finishPlan (досрочное завершение владельцем) и escape остаются здесь — они
    санкционированы грантом, не исполнением.
  - **`runGatePost({ event, frozenPlan, frozenArtifact, stepPtr, key, …, saveStep, removeFrozenPlan, escapeGrants, escapeConsumed, now })`**
    → `{ advanced: boolean, planComplete: boolean, message? }`. Пере-вывод
    `decideMode`; на `allow + advance` — `saveStep(advanceTo)` (+ `removeFrozenPlan`
    при `planComplete`). PostToolUse **никогда не блокирует** (инструмент уже
    исполнился) — возврат информационный.

## Алгоритм main() {#D2}

1. Прочитать событие; определить `event.hook_event_name`.
2. Загрузить `key`, `frozenPlan`, `frozenArtifact`, `stepPtr` (как сейчас).
3. Если `PreToolUse`: `r = runGatePre(...)`; `exitDecision({ block: r.block, … })`.
4. Если `PostToolUse`: `r = runGatePost(...)`; **никогда не блокирует**
   (`exitDecision({ block:false })`); при сбое — fail-loud WARN в stderr, exit 0.
5. Любой иной/неизвестный `hook_event_name`: безопасный no-op (exit 0) —
   не блокируем (Pre-ветка единственная блокирующая).
6. Сохранить panic-ветку только для **PreToolUse** (fail-CLOSED + escape владельца);
   PostToolUse-сбой fail-safe (не блок).

## Крайние случаи {#D3}

- **RED-шаг** (`vitest` exit≠0): инструмент исполнился → PostToolUse сработал →
  сдвиг. Падение теста — это «шаг сделан», указатель идёт дальше.
- **Поздний хук блокирует** (skill-discipline) / **user-deny**: инструмент не
  исполнился → PostToolUse не сработал → сдвига нет. Это и есть фикс (лечит оба).
- **Сбой PostToolUse** (fs-ошибка): указатель не сдвинут → следующий матчащий
  вызов повторит шаг. Fail-safe: стена по-прежнему гейтит каждое действие против
  плана (не дыра). Post — fail-loud (WARN), чтобы владелец заметил.
- **Идемпотентность:** PostToolUse один на исполнение → ровно один `saveStep`.
  Двойного сдвига нет.
- **Завершение плана:** `removeFrozenPlan`-по-`planComplete` переезжает в Post —
  печать снимается только после исполнения последнего шага (а не до). finishPlan
  (досрочно владельцем) остаётся в Pre — он от исполнения не зависит.
- **Осиротевшее намерение:** при skill-discipline-блоке Pre уже записал намерение,
  но действия не было → `enforce-reconcile.findOrphanIntents` даст benign WARN
  `record-without-action`. Терпимо (редко, WARN-only, точно отражает «заблокировано»).

## Конвенция кода {#D4}

ES-модуль `tools/enforce-supreme-gate.mjs` (как есть). Чистые `runGatePre` /
`runGatePost` экспортируются (тестируемы в изоляции, как текущий `runGate`).
Инъекция I/O (journal/saveStep/removeFrozenPlan) сохраняется. Без новых
зависимостей. `decide`/`decideMode`/`actionOf`/`resolveStepPtr`/`signStepState` —
не трогаем (на них завязаны другие модули и тесты).

## Критерий приёмки {#D5}

- TDD: новые юнит-тесты на `runGatePre` (не двигает указатель: при allow+advance
  `saveStep` не вызван; журнал-намерение записан; блок при несовпадении) и
  `runGatePost` (двигает при allow+advance; не двигает при seed/observe/escape/
  несовпадении; `removeFrozenPlan` при planComplete; никогда не блокирует).
- Регрессионный сценарий рассинхрона воспроизведён тестом: Pre-allow без
  `saveStep` → стороннее «исполнение не случилось» → указатель остался на месте.
- Полная регрессия tools-only GREEN: `npx vitest run --root app --config vitest.config.tools.mjs`
  (база 3928 passed + 2 skip; новые тесты добавляются сверху, существующие — адаптируются под расщепление).

## Влияние на владельца (settings.json) {#D6}

Обязательная правка: зарегистрировать `enforce-supreme-gate` **дополнительно** на
**PostToolUse** matcher `'*'` (PreToolUse-регистрация сохраняется). Без неё Post-ветка
не вызывается → указатель не двигается вовсе (план не идёт). Это owner-шаг;
выполняется при возврате стены из maintenance.

## Вне объёма (YAGNI)

- Не меняем `enforce-reconcile` (остаётся WARN-only сверкой).
- Не меняем порядок прочих хуков и не трогаем skill-discipline (он корректен; баг
  был во взаимодействии, а не в нём).
- Не меняем формат файла указателя / подпись / привязку plan_id.

```verified-context-json
[{"id":"C1","kind":"EXTRACTED","ref":"tools/enforce-supreme-gate.mjs","anchor":"saveStep(r.advanceTo)"}]
```
