diff --git a/docs/superpowers/specs/2026-06-09-router-mentor-A1-judge-gate2-wiring-design.md b/docs/superpowers/specs/2026-06-09-router-mentor-A1-judge-gate2-wiring-design.md new file mode 100644 index 00000000..cc5dfbc1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-router-mentor-A1-judge-gate2-wiring-design.md @@ -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 "/app" +--config "/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`.