spec(router-gate): Level 4 hard-wall enforcement architecture design

Single PreToolUse router-gate hook replaces 5 existing hooks
(chain-recommendation / classifier-match / graph-first /
semgrep-security / override-limit) + override-vocab.json.

Key principles:
- Hard wall — no inline overrides, no substring-match vocab
- User approval everywhere for router output (single + chains)
- Direct invocations (slash-commands, explicit 'use X') bypass
- Read-only baseline (Read/Grep/Glob/LS/TodoWrite/AskUser) always allowed
- All decisions logged to router-gate-decisions.jsonl

Observability migration:
- Loses: override-usage.jsonl, hook-outcomes.jsonl Cut 11
- Gains: router-gate-decisions.jsonl + 3 new brain-retro tables
- Etap 6 brain-retro adaptation included in epic

Implementation 6-8.5 hours across 6 etap'ов.
Risk: 7 preserved hooks lose their findOverride escape valves
(except rationalization-audit) — explicit acknowledged risk.

Driver: brain-retro #10 (override events 12->679 in 4 days),
self-retrospect #2 (2/5 commitments broken in 6 hours).

User-approved Section by section (1-5) via AskUserQuestion.

cspell-words.txt += вокабуляр / Бypass / sess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-29 05:06:36 +03:00
parent 5e103ef5b5
commit 7a43c175d0
3 changed files with 338 additions and 2 deletions
+3
View File
@@ -1863,3 +1863,6 @@ nohup
сматчить
тригернёт
суппрессить
вокабуляр
Бypass
sess
+2 -2
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-29T01:44:49.392Z
Last updated: 2026-05-29T01:47:17.763Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -110,7 +110,7 @@ Episodes since last run: 542 / threshold: 10
| Фраза | За всё время | За сегодня |
|---|---|---|
| `recovery` | 901 | 4 |
| `ремонт инфраструктуры` | 186 | 1 |
| `ремонт инфраструктуры` | 192 | 7 ⚠️ |
| `без скилов` | 185 | 7 ⚠️ |
| `срочно` | 93 | 0 |
| `memory dump` | 17 | 0 |
@@ -0,0 +1,333 @@
# Router-gate hard wall — Дизайн-спецификация (Уровень 4)
**Дата:** 2026-05-28
**Автор:** Claude (controller Opus 4.7) под руководством заказчика Дмитрия
**Статус:** Approved by owner — готов к плану implementation
**Тип:** feature — enforcement architecture rewrite
**Предшественник:** [docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md](../plans/2026-05-28-router-discipline-level-1-2.md) (Уровни 1+2, merged ранее в день)
---
## 1. Цель и контекст
Закрыть все обходы (bypass paths) от controller'а (Claude) к роутеру (classifier-driven skill recommendations). После реализации controller физически не может обойти router-рекомендации без явного разрешения заказчика.
### Триггер
Brain-retro #10 trend analysis показал катастрофический рост override-events: **12 → 229 → 348 → 679** срабатываний за 4 дня (25-28.05.2026). Из них `recovery` 525 событий за один день. Это паттерн — controller использует override-вокабуляр как привычку, не аварийный клапан. Дисциплина без enforcement не держится.
Self-retrospect #2 (28.05 вечер) подтвердил: 5 commitments утреннего self-retrospect сломаны 2 из 5 за 6 часов. Без enforcement-хука "привычку не удержать" — этот тезис теперь подтверждён двумя независимыми данными.
### Что было до
Архитектура enforcement до этого эпика:
- 5 PreToolUse-хуков с per-rule override-фразами из общего vocab.json.
- 1 файл `tools/enforce-override-vocab.json` с 7 фразами обхода (`без скилов`, `direct ok`, `срочно`, `быстрый коммит`, `recovery`, `memory dump`, `ремонт инфраструктуры`).
- 1 хук `enforce-override-limit.mjs` со счётчиком per-day=5 на использование каждой фразы.
- Уровни 1+2 (merged этим утром) сузили scope `recovery` и `ремонт инфраструктуры`, добавили per-rate-window, понизили classifier-match threshold с 0.8 до 0.6, добавили inline router-skip с 50+ chars.
Дырки которые Уровни 1+2 не закрыли:
1. **Одна override-фраза снимает 5 PreToolUse хуков разом** — даже после сужения scope, фраза всё ещё мульти-suppressor.
2. **Router-skip как inline self-bypass** — controller сам себя освобождает от рекомендации (хоть и с 50+ chars обоснованием).
3. **Глобальный override-vocab по substring-match** — никакого user explicit consent required.
### Что хочет заказчик
«Hard wall. Никаких обходов. Разрешение на любой обход даёт только заказчик. Все router-рекомендации (одиночные и цепочки) — через явную санкцию заказчика. Любая неясная ситуация — controller ничего не делает сам, спрашивает заказчика с честными рекомендациями.»
---
## 2. Принципы дизайна
1. **Hard wall, no escape valves в коде.** Бypass возможен только через AskUserQuestion с явным выбором заказчика. Никаких inline phrases, substring-match overrides, self-bypass паттернов.
2. **User approval everywhere для router output.** Каждая router-рекомендация (одиночный скил и цепочка) проходит через AskUserQuestion перед исполнением. Заказчик одобряет / выбирает альтернативу / останавливает.
3. **Прямые поручения заказчика — без переспрашивания.** Когда заказчик в prompt'е явно указал скил (`/brain-retro`, «вызови writing-plans», «делай subagent-driven») — это direct invocation, не router-recommendation, без AskUserQuestion gate.
4. **Read-only действия — всегда разрешены.** Read / Grep / Glob / LS / TodoWrite / read-only MCP / **AskUserQuestion** — безопасная база, никогда не блокируется.
5. **Honest reasoning required.** Каждое AskUserQuestion включает мои честные рекомендации + объяснение причин. Никакого размытия, никаких рационализаций.
6. **Recovery — explicit acknowledged risk.** Если gate ошибочно заблокирует и AskUserQuestion не сработает — единственный путь = ручная правка заказчиком файлов `.claude/settings.json` / `~/.claude/runtime/router-state-<sess>.json` / `~/.claude/runtime/chain-state-<sess>.json`. Заказчик соглашается быть recovery-каналом за хождение в hard wall.
7. **All decisions logged.** Каждое срабатывание gate (allow / block / unlock) пишется в JSONL-файл для post-hoc анализа в brain-retro.
---
## 3. Архитектура
### Новый компонент: `tools/enforce-router-gate.mjs`
Единственный PreToolUse-хук, регистрируется в `.claude/settings.json` с `matcher: ""` (все tools). Pure decision-функция + тонкая I/O обёртка.
На каждый tool-call хук:
1. Читает classifier-output последнего prompt'а из `~/.claude/runtime/router-state-<sess>.json` (уже существует — пишется при UserPromptSubmit роутером).
2. Читает chain-state из `~/.claude/runtime/chain-state-<sess>.json` (новый файл — какой шаг цепочки уже пройден across-turns).
3. Анализирует transcript текущего turn'а — был ли вызван AskUserQuestion или matching Skill/Task в этом ходе.
4. Резолвит набор «допустимых tools» по правилам Decision Flow (раздел 4).
5. Возвращает `decision: allow` (silent pass) или `decision: block` (с reason).
6. Пишет запись в `~/.claude/runtime/router-gate-decisions.jsonl`.
### Удаляются как класс
| Файл | Размер | Содержание |
|---|---|---|
| `tools/enforce-chain-recommendation.mjs` + test | ~370 lines | Chain enforcement, заменяется новым gate |
| `tools/enforce-classifier-match.mjs` + test | ~190 lines | Single-node enforcement, заменяется новым gate |
| `tools/enforce-graph-first.mjs` + test | ~150 lines | Graph-first rule, теперь через router recommendation |
| `tools/enforce-semgrep-security.mjs` + test | ~135 lines | Semgrep-security, теперь через router recommendation |
| `tools/enforce-override-limit.mjs` + test | ~150 lines | Override-счётчик, обходов больше нет |
| `tools/enforce-override-vocab.json` | ~80 lines | 7 фраз обходов |
В `tools/enforce-hook-helpers.mjs` функции `findOverride`, `findOverrideAttempt`, `loadOverrideVocab` превращаются в stubs (возвращают null / null / `{phrases: []}`). Это сохраняет совместимость с 6 preserved хуками которые их вызывают, без правки их кода. Соответствующие unit-тесты переписываются под stub-поведение.
Из `.claude/settings.json` снимаются регистрации удалённых хуков.
### Сохраняются (с патчем на «без vocab»)
7 хуков с собственной семантикой (не про router-routing):
| Хук | Семантика | Эффект удаления vocab |
|---|---|---|
| `enforce-tdd-gate` | Test перед production-code edit | Теряет escape-фразы `срочно` / `быстрый коммит` / `ремонт инфраструктуры`. Hard-lock — починка самого TDD-gate требует ручной правки settings.json заказчиком |
| `enforce-coverage-verify` | Stop-hook требует `coverage:` строку | Теряет `без скилов` / `direct ok`. Каждый ответ с tool_use требует coverage аннотацию |
| `enforce-memory-coverage` | Memory-write требует memory-sync coverage | Теряет `memory dump`. Каждое memory-редактирование требует явную аннотацию |
| `enforce-verify-before-push` | Свежий verify-sentinel перед commit/push | Теряет `срочно` / `быстрый коммит` / `ремонт инфраструктуры`. Hard-lock для починки verify-инфры |
| `enforce-rationalization-audit` | Soft detector, не блокирует | Не использует findOverride — нет эффекта |
| `enforce-prompt-injection` | Inject контекст-блок в UserPromptSubmit | Использует findOverride для surface активных overrides. После удаления — surface пустой |
| `enforce-branch-switch` | Block dangerous git-операции | Теряет `recovery`. Hard-lock для `git rebase` / `git reset --hard` — требует explicit `BRANCH-SWITCH-CONFIRMED` маркер в response каждый раз |
Из 7 preserved хуков 6 используют `findOverride` (все кроме `enforce-rationalization-audit`). После удаления vocab.json эти вызовы будут возвращать `null` всегда — hooks работают корректно, никаких изменений в их коде не требуется.
Это **explicit acknowledged risk** — заказчик соглашается что починка любого из 7 preserved хуков требует его ручного вмешательства.
### Новый файл состояния
`~/.claude/runtime/chain-state-<sess>.json`:
```
{
"chain_active": ["#55", "#19", "#56"],
"chain_step": 1,
"initialized_at": "2026-05-28T20:00:00.000Z",
"last_step_at": "2026-05-28T20:15:30.000Z"
}
```
Инициализируется когда router выдаёт chain. Обновляется при matching skill invocation (chain_step++). Очищается когда `chain_step >= chain_active.length` или router даёт другую цепочку (обнуляется и переинициализируется).
### Новый файл логирования
`~/.claude/runtime/router-gate-decisions.jsonl`:
```
{"ts": "2026-05-28T20:00:01.123Z", "session_id": "abc", "tool_name": "Edit", "decision": "block", "reason": "recommendation #19 not invoked, AskUserQuestion not called yet", "state": {"rec_node": "#19", "rec_chain": [], "chain_step": 0, "askuser_called": false, "skill_invoked_matching": false, "is_direct_invocation": false}}
```
Append-only, ~1KB на запись × ~300 записей/день = ~300KB/день. Читается brain-retro и self-retrospect.
---
## 4. Decision Flow (логика gate)
Gate определяет одно из 4 поведений на основе state:
### Поведение 1 — Прямое поручение заказчика (Direct invocation)
Триггер: prompt начинается со slash-команды (`/brain-retro`, `/code-review`, etc), ИЛИ содержит явное `вызови X` / `делай subagent-driven` / `используй superpowers:writing-plans`.
Детектор: regex поиск в `lastUserPromptText` для:
- `^/[a-z0-9_-]+(\s|$)` — slash-command в начале prompt'а
- `(?:вызови|используй|делай|invoke)\s+(?:superpowers:)?[a-z0-9_-]+` — явное указание скила
Если совпадение И tool-call матчит указанный скил → **allow** (без AskUserQuestion gate).
Если совпадение И tool-call НЕ матчит → **block** (заказчик указал X, а ты вызываешь Y).
### Поведение 2 — Роутер дал одиночную рекомендацию
Триггер: `rec_node !== null AND rec_chain.length === 0`.
| Состояние turn'а | Tool | Решение |
|---|---|---|
| askuser_called=false, skill_invoked_matching=false | Read/Grep/Glob/LS/TodoWrite/AskUserQuestion/read-only MCP | allow (safe base) |
| askuser_called=false, skill_invoked_matching=false | Skill matching rec_node / Task subagent_type matching | allow + unlock turn |
| askuser_called=false, skill_invoked_matching=false | Edit/Write/MultiEdit/NotebookEdit/Bash/Skill non-matching/Task non-matching | block — «Router рекомендовал X, вызови AskUserQuestion с предложениями» |
| askuser_called=true, skill_invoked_matching=false | Любой tool | allow (заказчик уже ответил, выполняй выбранный путь) |
| skill_invoked_matching=true | Любой tool | allow (skill уже выполнен, продолжаем работу) |
### Поведение 3 — Роутер дал цепочку
Триггер: `rec_chain.length >= 1` (включая chain длины 1).
Gate инициализирует chain-state (если не инициализирована) с `chain_active = rec_chain, chain_step = 0`.
Если новый router-state даёт ДРУГОЙ rec_chain (не пустой и не равный текущему chain_active) — chain-state переинициализируется с новым chain. Если новый router-state даёт пустой rec_chain — текущая chain_state сохраняется (мы внутри уже идущей цепочки).
`expected_node = chain_active[chain_step]`.
| Состояние turn'а | Tool | Решение |
|---|---|---|
| askuser_called=false, expected_skill_invoked=false | Safe base | allow |
| askuser_called=false, expected_skill_invoked=false | Skill matching expected_node / Task subagent_type matching | allow + unlock turn + chain_step++ |
| askuser_called=false, expected_skill_invoked=false | Любой mutating tool / Skill non-matching | block — «Цепочка [...], сейчас ждём шаг N (X). Вызови AskUserQuestion для одобрения» |
| askuser_called=true | Любой tool | allow |
| expected_skill_invoked=true | Любой tool | allow + chain_step++ |
| chain_step >= chain_active.length | Любой tool | allow (chain complete, clear chain-state) |
### Поведение 4 — Роутер молчит
Триггер: `rec_node === null AND rec_chain.length === 0 AND chain-state empty`.
| Состояние turn'а | Tool | Решение |
|---|---|---|
| askuser_called=false | Safe base | allow |
| askuser_called=false | Любой mutating tool (Edit/Write/Bash/Skill/Task) | block — «Роутер молчит. Вызови AskUserQuestion с 1/2+/0 форматом по количеству подходящих скилов» |
| askuser_called=true | Любой tool | allow (заказчик ответил, выполняй) |
### AskUserQuestion-форматы для Поведения 4
| Сколько подходящих скилов вижу | Формат AskUserQuestion |
|---|---|
| 2 и больше | Перечисляю варианты, явно указываю который рекомендую + почему. Заказчик выбирает |
| Ровно 1 | Один вариант — спрашиваю «делать со скилом X или без него?» |
| Ни одного | Спрашиваю «продолжить direct без скила или остановиться?» |
В **любом** AskUserQuestion (Поведения 2/3/4) controller обязан указать:
- Что роутер рекомендовал (или что он молчит).
- Моя честная оценка — подходит ли рекомендация, если нет — почему.
- 3-4 варианта на выбор с цена каждого (плюсы / минусы).
---
## 5. Безопасная база (всегда разрешено)
Эти tools never blocked, любое поведение:
- `Read` — просмотр файлов
- `Grep` — поиск по содержимому
- `Glob` — поиск по имени
- `LS` — список файлов
- `TodoWrite` — мой список задач (внутренний)
- `AskUserQuestion` — обращение к заказчику (ключевой канал)
- `ListMcpResourcesTool` — список MCP-ресурсов
- `ReadMcpResourceTool` — чтение MCP-ресурса
- Текстовый ответ (нет tool_use)
---
## 6. Recovery при lockout
### Базовый принцип
Когда gate (или один из 7 preserved хуков) блокирует — controller **никогда** не принимает решение об обходе сам. Всегда один и тот же путь:
1. Текстовый ответ — что заблокировано, какое правило, **честное** объяснение почему controller туда пришёл (без размытия — если controller сам нарушил правило, говорит прямо).
2. `AskUserQuestion` с 3-4 вариантами для заказчика:
- Дать explicit разрешение на конкретное действие.
- Указать другой путь.
- Остановить работу.
3. Ждать ответа. Не действовать дальше.
### Если AskUserQuestion физически недоступен
Три уровня заказчик-side intervention:
**Уровень 1 — отключить конкретный хук.** В `.claude/settings.json` секции `PreToolUse` / `Stop` удалить запись про сломанный хук. Следующий ход — хук не сработает.
**Уровень 2 — выключить ВСЕ хуки.** Переименовать `.claude/settings.json``.claude/settings.json.disabled`.
**Уровень 3 — править router-state.** В `~/.claude/runtime/router-state-<sess>.json` заменить `recommended_node` и `recommended_chain` на null. Следующий tool-call gate увидит «молчание роутера» — пойдёт сценарий AskUserQuestion.
---
## 7. Логирование и наблюдаемость
### Новый источник данных
`~/.claude/runtime/router-gate-decisions.jsonl` (формат — раздел 3).
### Что теряется из brain-retro
- **`override-usage.jsonl`** — пустеет. Hook enforce-override-limit удаляется. Таблица «Override events trend by day» исчезает.
- **`hook-outcomes.jsonl` Cut 11 в текущей форме** — теряется. Buckets (`blocked` / `passed-with-skill` / `passed-inline-override` / `passed-global-override` / `passed-short-chain` / `passed-no-mutating`) больше не пишутся.
### Что искажается
- **Brain-retro Table 5 «outcome × node_chosen group»** — bucket `direct_ignored_rec` становится 0 физически (gate блокирует). Интерпретация меняется: теперь это либо approved direct либо stop.
- **Factor matrix `decision_provenance`** — `autonomous` почти исчезает для mutating tools. Доминирует `user_chose_from_options`. Снижается аналитическая разнообразность.
- **Factor matrix `path_type`** — всё mutating становится `regulated`, `improvised` только для read-only.
### Что добавляется в brain-retro
**Table 11-new — Router-gate decision distribution.** Buckets: `allow-baseline` / `allow-after-skill-match` / `allow-after-askuser` / `allow-direct-invocation` / `block-no-rec-no-askuser` / `block-recommendation-bypass-attempt` / `block-chain-step-wrong` / `unlock-after-skill`.
**Table 12-new — User approval patterns.** На AskUserQuestion в ответ на router-recommendation: `approved-as-recommended` / `approved-alternative-skill` / `approved-direct-no-skill` / `chose-stop`. Показывает — насколько часто я ловлю ошибку роутера vs насколько часто роутер прав.
**Table 13-new — Lockout incidents.** Когда controller попал в lockout (gate заблокировал + AskUserQuestion → заказчик решил). Per incident: какой хук сработал, моя честная причина, решение заказчика.
### Адаптация существующих таблиц и факторов
**Table 5 переименовать в «outcome × approval-source».** Buckets: `skill-via-router-approved` / `skill-via-direct-invocation` / `direct-after-askuser-approval` / `read-only-no-gate`.
**`decision_provenance` factor matrix** — добавить новые значения:
- `user_approved_router_rec` (соглашение с роутером)
- `user_approved_alternative` (выбрал альтернативу)
- `user_approved_direct` (разрешил direct без скила)
- `user_directed` (явная команда заказчика)
- `autonomous` остаётся только для read-only и conversation
**`path_type`** — добавить третье значение `gate_approved` (заказчик явно одобрил direct).
### Migration of historical data
`override-usage.jsonl` и `hook-outcomes.jsonl` остаются на диске как архив. Новый код их игнорирует. Brain-retro может опционально показывать «pre-migration baseline» как историческую справку.
---
## 8. Этапы реализации (укрупнённо)
Детальный план будет писаться отдельной сессией через `superpowers:writing-plans`. Канва:
| Этап | Содержание | Часы |
|---|---|---|
| 1 — Подготовка инфраструктуры | Pure decision-модуль + I/O обёртка + chain-state persistence + decision logger + ~30 unit-тестов + smoke-тесты | 2-3 |
| 2 — Удаление 5 хуков + vocab | Удалить 5 .mjs + 5 .test.mjs + vocab.json (11 файлов). 3 helper-функции (findOverride / findOverrideAttempt / loadOverrideVocab) в enforce-hook-helpers.mjs оставить как stubs — возвращают null/empty всегда. Это сохраняет совместимость с 6 preserved хуками (tdd-gate / coverage-verify / memory-coverage / verify-before-push / prompt-injection / branch-switch) без правки их кода. Регрессия GREEN | 1-2 |
| 3 — Регистрация в settings.json | Добавить router-gate, снять регистрации удалённых хуков. Smoke-test полной сессии | 0.5 |
| 4 — Документация Recovery | Памятка для заказчика по 3 уровням recovery (отдельный doc) | 1 |
| 5 — Прогон в реальной работе | Несколько дней наблюдения, brain-retro #11 — проверить новые таблицы | (не таск) |
| 6 — Brain-retro adaptation | Обновить `brain-retro-analyzer.mjs` (parse router-gate-decisions, новые cut-функции). SKILL.md mandatory tables 11→13 | 1.5-2 |
| **Итого** | | **6-8.5** |
### Риски миграции
- **Этап 2 порядок-чувствительный** — сначала превращаем helper-функции в stubs (возвращают null/empty), потом удаляем vocab.json и тесты vocab. Если удалить vocab.json первым, тесты helpers упадут до stub-конверсии.
- **Этап 3 без отката** — если gate ломается, controller остаётся без всех PreToolUse-хуков (поставили новый, старые удалили). Mitigation — feature-branch + push до Этапа 3 для snapshot.
---
## 9. Открытые вопросы (для плана implementation)
Эти вопросы решаются на этапе writing-plans, не сейчас:
- Точный формат AskUserQuestion message для каждого Поведения 2/3/4 — нужна шаблонная функция или жёсткие строки?
- Persistence chain-state при крэше Claude session — нужен периодический snapshot или достаточно текущего файла?
- Direct-invocation detection — точный regex для slash-команд и явных вызовов; покрытие угловых случаев («ну давай вызови ... что-то»).
- Логирование `direct_invocation` events — добавить отдельный bucket в decision log?
- Bash-команды как mutating — какой именно sub-pattern блокируем? Полное blacklist (`rm -rf` / `git push` / etc) или whitelist read-only (`git status` / `git log` / etc)?
---
## 10. Cross-refs
- Предшественник: [Уровни 1+2 план](../plans/2026-05-28-router-discipline-level-1-2.md)
- Brain-retro #10: [`docs/observer/notes/2026-05-28-brain-retro-10.md`](../../observer/notes/2026-05-28-brain-retro-10.md)
- Self-retrospect #2: [`docs/observer/notes/2026-05-28-self-retrospect-2.md`](../../observer/notes/2026-05-28-self-retrospect-2.md)
- Pravila §16 brain governance, §17 universal skill-coverage — будет обновляться отдельной задачей через `claude-md-management` после этого эпика.
- Текущая enforcement-архитектура: 5 PreToolUse хуков + vocab.json в `tools/`.