docs(router-mentor): A1 judge gate2 wiring design spec

This commit is contained in:
Дмитрий
2026-06-09 08:11:13 +03:00
parent 2f8586c57b
commit d2c5cb6b59
@@ -0,0 +1,138 @@
# A1 — Проводка судьи М4 на Гейт-2 (план), за флагом, $0 — дизайн
**Дата:** 2026-06-09 · **Эпик:** «роутер-наставник» · **Ветка:** `worktree-brainrepo`
**Кодовая фраза:** «роутер-наставник» · **Режим:** commit-not-push.
**Хвост реестра:** A1 / R-33 (часть). **Зависимость порядка:** A1 → A6 → A7 (судья раньше наставника).
---
## 1. Цель
Оживить судью Машины 4 на **одном** продукте — **плане реализации (Гейт-2)** — за рубильником
владельца, с нулевым расходом ($0) до включения. Это первая живая проводка судьи; объём намеренно
минимальный (один гейт), чтобы безопасно обкатать путь «детект → извлечение → модель → вердикт →
журнал» в режиме shadow до перевода в block.
**Вне scope A1** (делается позже): Гейт-1 (спека), Гейт-3 (результат), A2 (divergence/destructive),
правка пола/стен, регистрация в `settings.json` (рубильник владельца), наставник М3 (A7).
## 2. Контекст (что уже есть)
- `judge-engine.runJudge({ functionName, requiredLenses, llmCall, promptArgs })` — думающая часть
собрана: `llmCall(prompt) → {decision, slots, objections}` инъектируется; механика вокруг (валидация
слотов, под-прогоны, якоря, обратимость) fail-closed. `buildJudgePrompt({functionName, requiredLenses,
product, goal, cards})` — чистый.
- `judge-gate-config.judgeGateMode()` — режим `inert` ($0, нет флага/ключа) / `shadow` (active,
логирует, НЕ блокирует — D28) / `live-block` (active + MODE=block, блокирует на NO-GO).
`judgeActive` = флаг `ROUTER_MENTOR_JUDGE_ENABLED=1` И ключ keychain `router-mentor-judge`.
- `judge-orchestrator.finalGate({judgeDecision, floorBlocked})` — пол перевешивает «да» судьи;
`logVerdict({verdict, nowMs, journal})` — append-only журнал вердиктов (J8).
- `enforce-judge-gate` — тонкая обёртка-рубильник. Шов `runJudgeGate(_event)` сейчас заглушка
`{decision:'GO', wired:false}`. Регистрировать в `settings.json` нужно **обёртку**, не движок.
- Транспорт-эталон — `callAnthropicAPI` (как у классификатора), модель `CLASSIFIER_MODEL` (Sonnet).
## 3. Находка, которую дизайн обязан закрыть
Текущий `enforce-judge-gate.main()` в режиме `shadow` **не вызывает** `runJudgeGate` — сразу `allow`
(строка 50). Это противоречит контракту shadow из runbook («судья пишет вердикты в журнал, но НЕ
блокирует»). **A1 чинит main():** shadow обязан прогонять судью и логировать вердикт, но всегда
пропускать.
## 4. Компоненты
### 4.1 `extractGate2Product(event)` — детектор + извлечение (чистая)
Смотрит harness-событие. Возвращает либо `{ shouldJudge:false }`, либо
`{ shouldJudge:true, functionName:'gate2', product, goal, cards }`.
- **Срабатывает только** если событие — запись плана: `tool ∈ {Write, Edit, MultiEdit}` И путь под
`docs/superpowers/plans/*.md` (нормализованный, прямые/обратные слэши). Иначе `shouldJudge:false`.
- `product` = текст плана из `tool_input` (Write → `content`; Edit/MultiEdit → `new_string`/итог).
План виден ещё до записи (PreToolUse) — это и есть «продукт на суд».
- `goal` = best-effort цель плана: его секция цели (`## Цель`/`## Goal`/первый абзац) или пустая
строка, если не нашли (движок держит пустой якорь).
- `cards` = `[]` (для A1; карточки навыков для оценки плана не критичны — их подаёт наставник на A7).
Защита от over-block встроена сюда: на обычные тулы `shouldJudge:false` → модель не зовётся, $0.
### 4.2 `buildJudgeLlmCall({ apiKey, model, transport })` — фабрика живого `llmCall`
Возвращает `llmCall(prompt) → {decision, slots, objections}`:
- зовёт `transport` (по умолчанию `callAnthropicAPI`) с `{system, user}` из `buildJudgePrompt`,
ключ `apiKey` (keychain `router-mentor-judge`), модель `model` (Sonnet);
- парсит ответ модели (ожидается JSON со `slots`/`objections`/`decision`) в строгую форму;
- **битый/непарсящийся ответ → форма, которую `runJudge` отвергает** (невалидные слоты → NO-GO).
Fail-closed: модель не штампует GO мимо механики.
`transport` инъектируется (для тестов мок; в проде — `callAnthropicAPI`).
### 4.3 `runJudgeGate(event, deps)` — оркестрация шва
`deps = { mode?, key?, model?, transport?, journal?, nowMs? }` (инъекция; по умолчанию — реальные
резолверы конфига/ключа).
Порядок:
1. `judgeActive` (флаг + ключ). **Нет → нейтральный GO `{decision:'GO', wired:false}`, $0** (ни одного
вызова модели).
2. Активен → `extractGate2Product(event)`. `shouldJudge:false` → нейтральный GO, $0.
3. `shouldJudge:true` → `judge-engine.runJudge({ functionName:'gate2',
requiredLenses: requiredLensesFor('gate2'), llmCall: buildJudgeLlmCall(...), promptArgs:{product, goal, cards} })`.
Вернуть `{ decision, wired:true, verdict }`.
Любой бросок внутри → пробрасывается в дисциплинарный обработчик main() (fail-closed в live-block).
### 4.4 `main()` — три режима (правка находки §3)
- `inert` → `allow`, без прогона ($0).
- `shadow` → `runJudgeGate(event)` (живой llmCall, если есть что судить) → `logVerdict(...)` в журнал J8
→ **ВСЕГДА `allow`** (D28).
- `live-block` → `runJudgeGate(event)` → `logVerdict(...)` → `decide({mode, verdict, floorBlocked})`
(`finalGate`: NO-GO → блок; пол перевешивает).
Спенд во всех ветках за `judgeActive` → по умолчанию (нет флага/ключа) `inert`, $0.
## 5. Поток данных
```
PreToolUse event
│ main(): judgeGateMode()
├─ inert ──────────────────────────► allow ($0)
├─ shadow ─► runJudgeGate ─► extract gate2? ──нет──► neutral GO ($0) ─► log? нет ─► allow
│ └─да─► runJudge(live llmCall) ─► verdict ─► logVerdict ─► allow (D28)
└─ live-block ─► runJudgeGate ─► (то же) ─► verdict ─► logVerdict ─► finalGate ─► allow|block
```
## 6. Обработка ошибок
- Нет ключа/флага → `inert`/$0 (по конструкции).
- Парс ответа модели упал → невалидные слоты → `runJudge` отдаёт NO-GO (fail-closed). В shadow это
лишь логируется; в live-block — блок (но пол всё равно перевешивает через `finalGate`).
- Бросок транспорта/извлечения → в `live-block` дисциплинарный обработчик main() даёт блок
(fail-closed, как сейчас); в `shadow` — `allow` (shadow не блокирует никогда), вердикт в журнал не
пишем при упавшем прогоне (логируем только состоявшийся вердикт).
- `judgeActive` истинно, но `shouldJudge:false` → нейтральный GO, журнал не трогаем (нечего судить).
## 7. Тестирование (TDD, наблюдаемый RED)
Чистые функции — детерминированно:
- `extractGate2Product`: план-путь срабатывает; не-план / не-Write / иной путь → `shouldJudge:false`;
извлечение product/goal из Write и Edit; нормализация слэшей.
- `buildJudgeLlmCall`: валидный JSON-ответ → `{decision,slots,objections}`; битый ответ → форма,
которую `runJudge` отвергает (NO-GO).
- `runJudgeGate`: не активен → `{wired:false}`, транспорт не дёрнут (мок-счётчик 0); активен +
не-план → `{wired:false}`, транспорт не дёрнут; активен + план → `runJudge` с живым llmCall, вердикт.
- `main()` (инъекция конфига/транспорта/журнала): inert → allow без прогона; shadow → прогон + лог +
allow; live-block GO → allow; live-block NO-GO → block; пол blocked → block независимо от судьи.
Спенд-инвариант (ключевой): **без флага/ключа транспорт не вызывается ни в одной ветке** (мок-счётчик
вызовов = 0). Регрессия tools-only vitest в конце (через Bash: `npx vitest run --root "<wt>/app"
--config "<wt>/app/vitest.config.tools.mjs" <фильтр> --reporter dot`).
## 8. Границы и якоря
- A1 — только Гейт-2; не трогает Гейт-1/3, A2, пол, стены, нормативку, settings.json.
- Регистрация обёртки + флаг + ключ + перевод shadow→block — **рубильник владельца** (runbook A1, шаги владельца).
- commit-not-push. CLAUDE.md в worktree не трогаем (чужая зона).
- Цепочка кода после спеки/плана: `audit-context-building → sharp-edges → variant-analysis →
writing-plans → test-driven-development → verification-before-completion → regression`. НЕ
`requesting-code-review`.