# 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`.

## 9. Поправки после pre-code adversarial-цепочки (2026-06-09)

Цепочка (audit-context-building → sharp-edges → variant-analysis, сверка с живым v4-судьёй
`enforce-llm-judge-per-tool`) дала упрочнения дизайна — применяются поверх §4:

1. **Детектор только на `Write`** (SE-FIX-2). Гейт-2 судит **полный план**; `Edit`/`MultiEdit`
   на PreToolUse несут лишь фрагмент (old/new), не весь файл — судить фрагмент бессмысленно
   (ложные NO-GO). `writing-plans` создаёт план через `Write` (полный `content`) — момент приёмки
   ловится. Побочно снижает повторные срабатывания.
2. **judge-unavailable ≠ malformed verdict** (VA-FIX-1, зеркало v4 `{block:false, degraded:true}`).
   `callJudgeModel`: нет `apiKey` ИЛИ транспорт бросил → `{unavailable:true}` (без throw, $0);
   транспорт вернул текст, но парс невалиден → `{}`. `runJudgeGate`: `unavailable` →
   `{decision:'GO', wired:false, unavailable:true}` (degraded ALLOW + громкий WARN, **не** NO-GO).
   Только реальный невалидный вердикт → NO-GO (fail-closed). Различает «судья не может работать»
   (operator-misconfig → деградация, как `judgeHealth` floor-only) от «судья сказал НЕТ» (block).
3. **main() гибрид fail-философии** (VA-NOTE-2 + MUST-VERIFY-1). `exitDisciplineDecision` await'ит
   async-санк и fail-CLOSE только на throw/malformed. live-block: `await exitDisciplineDecision(async
   () => { v = await runJudgeGate(event); logRealVerdict(v); return decide({mode, verdict:v,
   floorBlocked:false}); })`. degraded-allow (`unavailable`) → decide(GO) → allow; реальный NO-GO →
   block; истинный баг кода (throw) → fail-CLOSE block. shadow: run + log-real + always allow.
4. **Спенд-семантика явная** (SE-DOC-1): **inert = $0** (нет флага/HMAC-ключа); **shadow = метеренный**
   (реальный LLM-вызов за каждую запись плана); **block = метеренный**. env строгие:
   `ROUTER_MENTOR_JUDGE_ENABLED` ровно `1`, `ROUTER_MENTOR_JUDGE_MODE` ровно `block`; опечатка →
   безопасный дефолт (inert/shadow).
5. **Логируем только реальный вердикт** (VA-OK-2, зеркало v4 `verdict!==undefined`): `unavailable`/
   degraded-allow → НЕ verdict-запись, а отдельная WARN-строка.
6. **Отложено (documented residuals):** дедуп по content-hash (SE-DEFER-1) + per-session budget-cap
   (VA-NOTE-1) — Write-only уже ограничивает частоту; revisit, если shadow-стоимость заметна.
7. **Подтверждено ✓:** движок `judge-engine`/orchestrator без `main()`/транспорта (мис-регистрация
   тратить не может); A1 стартует узко (Write-only) → не наследует v4 over-block по оси scope;
   `judgeActive` default-inert при опечатке флага/режима (secure-by-default).
