# A1 — Проводка судьи М4 на Гейт-2 (план), за флагом, $0 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Оживить судью М4 на одном продукте — плане реализации (Гейт-2) — за рубильником владельца, с нулевым расходом ($0) до включения, режим shadow по умолчанию.

**Architecture:** Шов `enforce-judge-gate.runJudgeGate` становится async: резолвит рубильник (флаг + HMAC-ключ судьи) → детектит «запись плана» → извлекает product/goal → префетчит вердикт живым `callAnthropicAPI` (ключ `ROUTER_LLM_KEY`, Sonnet) → парсит fail-closed → отдаёт в синхронный `judge-engine.runJudge` (движок М4 НЕ трогаем). `main()` чинится под три режима: inert ($0) / shadow (прогон + лог, не блокирует) / live-block (прогон + лог + `finalGate`).

**Tech Stack:** Node ESM (`tools/*.mjs`), vitest (tools-config), Anthropic via `callAnthropicAPI` (ProxyAPI/Sonnet).

**Спека:** `docs/superpowers/specs/2026-06-09-router-mentor-A1-judge-gate2-wiring-design.md` (commit `44c194bc`).

**Жёсткое:** commit-not-push; `--no-verify` запрещён гейтом (коммит штатный, lefthook не в PATH → хуки не гонятся); движок/пол/стены не ослаблять; CLAUDE.md в worktree не трогать; стейдж файлов поимённо.

**Запуск тестов (через Bash tool — PowerShell-гейт режет npx/node):**
```
npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" enforce-judge-gate --reporter dot
```
БЕЗ `cd`, абсолютный `--root`. Полная регрессия — фильтр убрать.

---

## Файловая структура

- **Modify:** `tools/enforce-judge-gate.mjs` — добавить чистые `parseJudgeResponse` / `extractGate2Product` / `callJudgeModel` / `logVerdictLine` / `buildVerdictEntry` / `runJudgeTurn`; переписать `runJudgeGate` (async) + `main()` (три режима). Импорты из `judge-engine` (`runJudge`, `requiredLensesFor`, `buildJudgePrompt`), `judge-orchestrator` (`logVerdict`, уже есть `finalGate`), `judge-gate-config` (`judgeActive`, уже есть `judgeGateMode`), `enforce-hook-helpers` (`runtimeDir`, уже есть `readStdin`/`parseEventJson`/`exitDecision`/`exitDisciplineDecision`), `router-classifier` (`callAnthropicAPI`), `router-config` (`CLASSIFIER_MODEL`), node `fs`/`path`.
- **Modify (test):** `tools/enforce-judge-gate.test.mjs` — обновить существующий seam-тест (`runJudgeGate` стал async → `await`) + добавить describe-блоки на новые функции.

Единственный файл кода — `enforce-judge-gate.mjs` (обёртка остаётся тонкой: транспорт/парс/детект/лог — чистые функции рядом, движок М4 не трогаем).

---

## Task 1: `parseJudgeResponse` — fail-closed парсер ответа модели

**Files:**
- Modify: `tools/enforce-judge-gate.mjs`
- Test: `tools/enforce-judge-gate.test.mjs`

- [ ] **Step 1: Написать падающий тест**

В `tools/enforce-judge-gate.test.mjs` добавить импорт `parseJudgeResponse` и блок:

```js
import { parseJudgeResponse } from './enforce-judge-gate.mjs';

describe('parseJudgeResponse — fail-closed', () => {
  it('валидный JSON → {slots, objections}', () => {
    const txt = JSON.stringify({ slots: { plan_soundness: 'план полный и связный' }, objections: [] });
    const r = parseJudgeResponse(txt);
    expect(r.slots.plan_soundness).toBe('план полный и связный');
    expect(Array.isArray(r.objections)).toBe(true);
  });
  it('JSON в ```json-заборе → распарсен', () => {
    const r = parseJudgeResponse('```json\n{"slots":{"x":"yyyyyyyy"},"objections":[]}\n```');
    expect(r.slots.x).toBe('yyyyyyyy');
  });
  it('битый текст → {} (движок отвергнет по слотам)', () => {
    expect(parseJudgeResponse('не json вовсе')).toEqual({});
  });
  it('массив/строка/пусто → {}', () => {
    expect(parseJudgeResponse('[1,2]')).toEqual({});
    expect(parseJudgeResponse('"строка"')).toEqual({});
    expect(parseJudgeResponse('')).toEqual({});
  });
  it('objections не массив → нормализуется в []', () => {
    const r = parseJudgeResponse(JSON.stringify({ slots: {}, objections: 'нет' }));
    expect(r.objections).toEqual([]);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `npx vitest run --root "...brainrepo/app" --config "...brainrepo/app/vitest.config.tools.mjs" enforce-judge-gate --reporter dot`
Expected: FAIL — `parseJudgeResponse is not exported` / не определена.

- [ ] **Step 3: Реализовать минимум**

В `tools/enforce-judge-gate.mjs` добавить:

```js
/**
 * Парс ответа модели в форму {decision?, slots, objections}. Fail-closed:
 * не-JSON / не-объект / массив / строка → {} (движок runJudge отвергнет по слотам → NO-GO).
 * objections нормализуется в массив. decision судьёй не используется (runJudge выводит сам),
 * но сохраняется для лога.
 */
export function parseJudgeResponse(text) {
  try {
    let s = String(text == null ? '' : text).trim();
    s = s.replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim();
    if (!s) return {};
    const o = JSON.parse(s);
    if (!o || typeof o !== 'object' || Array.isArray(o)) return {};
    return {
      decision: o.decision,
      slots: o.slots && typeof o.slots === 'object' ? o.slots : {},
      objections: Array.isArray(o.objections) ? o.objections : [],
    };
  } catch {
    return {};
  }
}
```

- [ ] **Step 4: Запустить — зелёный**

Run: тот же vitest-фильтр.
Expected: PASS (5 новых + старые).

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): parseJudgeResponse fail-closed parser"
```

---

## Task 2: `extractGate2Product` — детектор «запись плана» + извлечение

**Files:**
- Modify: `tools/enforce-judge-gate.mjs`
- Test: `tools/enforce-judge-gate.test.mjs`

- [ ] **Step 1: Написать падающий тест**

```js
import { extractGate2Product } from './enforce-judge-gate.mjs';

describe('extractGate2Product — детект плана + извлечение', () => {
  const planPath = 'docs/superpowers/plans/2026-06-09-foo.md';
  it('Write плана → shouldJudge, product=content, functionName=gate2', () => {
    const ev = { tool_name: 'Write', tool_input: { file_path: planPath, content: '# План\n## Цель\nсделать X\nшаги...' } };
    const r = extractGate2Product(ev);
    expect(r.shouldJudge).toBe(true);
    expect(r.functionName).toBe('gate2');
    expect(r.product).toContain('сделать X');
    expect(r.goal).toContain('сделать X');
    expect(r.cards).toEqual([]);
  });
  it('Edit плана → product=new_string', () => {
    const ev = { tool_name: 'Edit', tool_input: { file_path: planPath, new_string: 'новый шаг плана' } };
    expect(extractGate2Product(ev).product).toBe('новый шаг плана');
  });
  it('обратные слэши в пути → распознаётся', () => {
    const ev = { tool_name: 'Write', tool_input: { file_path: 'docs\\superpowers\\plans\\x.md', content: 'c' } };
    expect(extractGate2Product(ev).shouldJudge).toBe(true);
  });
  it('не-план путь → shouldJudge:false', () => {
    const ev = { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs', content: 'c' } };
    expect(extractGate2Product(ev).shouldJudge).toBe(false);
  });
  it('не Write/Edit (Bash) → shouldJudge:false', () => {
    expect(extractGate2Product({ tool_name: 'Bash', tool_input: { command: 'ls' } }).shouldJudge).toBe(false);
  });
  it('спека (specs/) → shouldJudge:false (это не Гейт-2)', () => {
    const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x.md', content: 'c' } };
    expect(extractGate2Product(ev).shouldJudge).toBe(false);
  });
  it('пустое/битое событие → shouldJudge:false', () => {
    expect(extractGate2Product(null).shouldJudge).toBe(false);
    expect(extractGate2Product({}).shouldJudge).toBe(false);
  });
});
```

- [ ] **Step 2: Запустить — падает**

Expected: FAIL — `extractGate2Product is not exported`.

- [ ] **Step 3: Реализовать минимум**

```js
const PLAN_PATH_RE = /(^|[\/\\])docs[\/\\]superpowers[\/\\]plans[\/\\][^\/\\]+\.md$/i;
const PLAN_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);

/** Цель плана: секция ## Цель / ## Goal (до след. заголовка) или первый непустой абзац. */
function extractGoal(text) {
  const m = String(text).match(/^##\s*(?:Цель|Goal)\b[^\n]*\n([\s\S]*?)(?:\n##\s|$)/im);
  if (m && m[1].trim()) return m[1].trim();
  const para = String(text).split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#'));
  return para || '';
}

/**
 * Детект «запись плана реализации» (Гейт-2) + извлечение продукта на суд.
 * Срабатывает только для Write/Edit/MultiEdit по пути docs/superpowers/plans/*.md.
 * Иначе {shouldJudge:false} → судья не зовётся ($0, защита от over-block).
 */
export function extractGate2Product(event) {
  const tool = event && event.tool_name;
  const input = (event && event.tool_input) || {};
  const filePath = String(input.file_path || '');
  if (!PLAN_TOOLS.has(tool) || !PLAN_PATH_RE.test(filePath)) return { shouldJudge: false };
  const product = String(input.content ?? input.new_string ?? '').trim();
  return { shouldJudge: true, functionName: 'gate2', product, goal: extractGoal(product), cards: [] };
}
```

- [ ] **Step 4: Запустить — зелёный**

Expected: PASS.

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): extractGate2Product plan detector"
```

---

## Task 3: `callJudgeModel` — async префетч вердикта (транспорт + парс + спенд-гейт)

**Files:**
- Modify: `tools/enforce-judge-gate.mjs`
- Test: `tools/enforce-judge-gate.test.mjs`

- [ ] **Step 1: Написать падающий тест** (транспорт инъектируется, API-ключ из env инъектируется)

```js
import { callJudgeModel } from './enforce-judge-gate.mjs';

describe('callJudgeModel — транспорт + парс + спенд-гейт', () => {
  const promptArgs = { product: 'p', goal: 'g', cards: [] };
  const lenses = ['plan_soundness'];
  it('нет apiKey → транспорт НЕ зовётся, вердикт {} (fail-closed)', async () => {
    let calls = 0;
    const transport = async () => { calls++; return '{}'; };
    const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: '', transport });
    expect(calls).toBe(0);
    expect(r).toEqual({});
  });
  it('есть apiKey → транспорт зовётся, ответ распарсен', async () => {
    let calls = 0;
    const transport = async (pm, opts) => { calls++; expect(opts.apiKey).toBe('K'); expect(pm.system).toMatch(/судья/i); return JSON.stringify({ slots: { plan_soundness: 'ок-ок-ок' }, objections: [] }); };
    const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
    expect(calls).toBe(1);
    expect(r.slots.plan_soundness).toBe('ок-ок-ок');
  });
  it('транспорт бросил → {} (fail-closed, не пробрасывает)', async () => {
    const transport = async () => { throw new Error('net'); };
    const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
    expect(r).toEqual({});
  });
});
```

- [ ] **Step 2: Запустить — падает**

Expected: FAIL — `callJudgeModel is not exported`.

- [ ] **Step 3: Реализовать минимум**

Добавить импорты в шапку `enforce-judge-gate.mjs`:

```js
import { runJudge, requiredLensesFor, buildJudgePrompt } from './judge-engine.mjs';
import { logVerdict } from './judge-orchestrator.mjs';
import { judgeActive } from './judge-gate-config.mjs';
import { runtimeDir } from './enforce-hook-helpers.mjs';
import { callAnthropicAPI } from './router-classifier.mjs';
import { CLASSIFIER_MODEL } from './router-config.mjs';
import fsDefault from 'node:fs';
import { join } from 'node:path';
```

(`decide`/`runJudgeGate` уже импортируют `finalGate` из `judge-orchestrator` — оставить.)

Затем функцию:

```js
const JSON_DIRECTIVE = [
  'Ответь СТРОГО валидным JSON без пояснений и без markdown-забора:',
  '{"slots":{"<линза>":"<непустая строка ≥8 симв>"},',
  ' "objections":[{"verdict":"NO","anchor":{"kind":"spec_section|card_need|test_name|failed_criterion|observation","ref":"<конкретика>"},"severity":"fatal|heavy|light","reversible":true|false}]}',
  'Слот на КАЖДУЮ линзу обязателен. Возражение без якоря станет советом (не блок).',
].join('\n');

/**
 * Префетч вердикта судьи (async): строит промпт, зовёт транспорт, парсит fail-closed.
 * Спенд-гейт: без apiKey транспорт НЕ зовётся → {} (движок отвергнет по слотам → NO-GO).
 * Бросок транспорта проглатывается → {} (fail-closed; решение принимает механика runJudge/finalGate).
 * transport инъектируется (тест — мок; прод — callAnthropicAPI).
 */
export async function callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) {
  if (!apiKey) return {};
  const base = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
  const prompt = { system: base.system + '\n' + JSON_DIRECTIVE, user: base.user };
  try {
    const text = await transport(prompt, { apiKey, model });
    return parseJudgeResponse(text);
  } catch {
    return {};
  }
}
```

- [ ] **Step 4: Запустить — зелёный**

Expected: PASS.

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): callJudgeModel async transport + spend-gate"
```

---

## Task 4: `runJudgeGate` (async) — оркестрация шва

**Files:**
- Modify: `tools/enforce-judge-gate.mjs` (переписать `runJudgeGate`)
- Test: `tools/enforce-judge-gate.test.mjs` (обновить старый seam-тест на `await` + новые)

- [ ] **Step 1: Обновить существующий тест + написать новые (падающие)**

Заменить старый блок `describe('runJudgeGate seam ...')` на:

```js
describe('runJudgeGate (async) — рубильник + детект + префетч', () => {
  const planEv = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n## Цель\nцель плана тут\nшаг' } };
  const bashEv = { tool_name: 'Bash', tool_input: { command: 'ls' } };
  const okText = JSON.stringify({ slots: { spec_fidelity: 'слот1слот1', verifiability: 'слот2слот2', plan_soundness: 'слот3слот3', execution_risk: 'слот4слот4', step_clarity: 'слот5слот5' }, objections: [] });

  it('не активен (нет флага/ключа) → wired:false, транспорт не зовётся', async () => {
    let calls = 0;
    const deps = { judgeActiveImpl: () => false, transport: async () => { calls++; return okText; } };
    const r = await runJudgeGate(planEv, deps);
    expect(r.decision).toBe('GO');
    expect(r.wired).toBe(false);
    expect(calls).toBe(0);
  });
  it('активен, но не план (Bash) → wired:false, транспорт не зовётся ($0)', async () => {
    let calls = 0;
    const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } };
    const r = await runJudgeGate(bashEv, deps);
    expect(r.wired).toBe(false);
    expect(calls).toBe(0);
  });
  it('активен, нет ROUTER_LLM_KEY → транспорт не зовётся, вердикт NO-GO (fail-closed)', async () => {
    let calls = 0;
    const deps = { judgeActiveImpl: () => true, apiKey: '', transport: async () => { calls++; return okText; } };
    const r = await runJudgeGate(planEv, deps);
    expect(calls).toBe(0);
    expect(r.wired).toBe(true);
    expect(r.decision).toBe('NO-GO');
    expect(r.verdict.functionName).toBe('gate2');
  });
  it('активен + план + чистый вердикт → GO, wired:true, functionName=gate2', async () => {
    const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText };
    const r = await runJudgeGate(planEv, deps);
    expect(r.wired).toBe(true);
    expect(r.decision).toBe('GO');
    expect(r.verdict.functionName).toBe('gate2');
  });
  it('активен + план + якорное тяжёлое NO → NO-GO', async () => {
    const noText = JSON.stringify({ slots: { spec_fidelity: 'слот1слот1', verifiability: 'слот2слот2', plan_soundness: 'слот3слот3', execution_risk: 'слот4слот4', step_clarity: 'слот5слот5' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§3' }, severity: 'heavy', reversible: false }] });
    const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText };
    const r = await runJudgeGate(planEv, deps);
    expect(r.decision).toBe('NO-GO');
  });
});
```

- [ ] **Step 2: Запустить — падает**

Expected: FAIL — старый `runJudgeGate` синхронный (`r.decision` undefined на Promise) / новые deps не учтены.

- [ ] **Step 3: Переписать `runJudgeGate`**

Заменить текущую заглушку (строки ~40-42) на:

```js
/**
 * Шов судьи (async, §8): рубильник → детект плана → префетч живого вердикта.
 * 1) не активен (нет флага/HMAC-ключа судьи) → нейтральный GO, wired:false, $0.
 * 2) активен, но не «запись плана» → нейтральный GO, wired:false, $0 (защита от over-block).
 * 3) активен + план → callJudgeModel (apiKey=ROUTER_LLM_KEY) → judge-engine.runJudge
 *    (движок М4 не трогаем; под-прогоны не требуем — subRunsRequired:[]). functionName='gate2'
 *    приклеивается к вердикту для лога. apiKey/transport/judgeActiveImpl инъектируются для тестов.
 */
export async function runJudgeGate(event, deps = {}) {
  const judgeActiveImpl = deps.judgeActiveImpl || judgeActive;
  if (!judgeActiveImpl()) return { decision: 'GO', wired: false };
  const g = extractGate2Product(event);
  if (!g.shouldJudge) return { decision: 'GO', wired: false };
  const apiKey = deps.apiKey !== undefined ? deps.apiKey : process.env.ROUTER_LLM_KEY;
  const requiredLenses = requiredLensesFor('gate2');
  const raw = await callJudgeModel({
    functionName: 'gate2', requiredLenses,
    promptArgs: { product: g.product, goal: g.goal, cards: g.cards },
    apiKey, model: deps.model, transport: deps.transport,
  });
  const verdict = runJudge({ functionName: 'gate2', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs: { product: g.product, goal: g.goal, cards: g.cards } });
  return { decision: verdict.decision, wired: true, verdict: { ...verdict, functionName: 'gate2' } };
}
```

- [ ] **Step 4: Запустить — зелёный**

Expected: PASS (5 новых; старый seam-тест удалён/заменён).

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): async runJudgeGate orchestration"
```

---

## Task 5: `logVerdictLine` + `buildVerdictEntry` — shadow-лог вердиктов (J8)

**Files:**
- Modify: `tools/enforce-judge-gate.mjs`
- Test: `tools/enforce-judge-gate.test.mjs`

- [ ] **Step 1: Написать падающий тест** (fs инъектируется)

```js
import { logVerdictLine, buildVerdictEntry } from './enforce-judge-gate.mjs';

describe('shadow-лог вердиктов', () => {
  it('buildVerdictEntry: вердикт+событие → entry {kind, functionName, decision, at}', () => {
    const e = buildVerdictEntry({ decision: 'GO', verdict: { functionName: 'gate2', decision: 'GO' } }, 123);
    expect(e.kind).toBe('verdict');
    expect(e.functionName).toBe('gate2');
    expect(e.decision).toBe('GO');
    expect(e.at).toBe(123);
  });
  it('logVerdictLine: JSONL-строка дописывается в файл (инъекция fs)', () => {
    const writes = [];
    const fsImpl = { appendFileSync: (p, s) => writes.push([p, s]), mkdirSync: () => {} };
    logVerdictLine({ kind: 'verdict', decision: 'GO' }, { fsImpl, dir: '/r' });
    expect(writes).toHaveLength(1);
    expect(writes[0][0]).toMatch(/judge-verdicts\.jsonl$/);
    expect(writes[0][1]).toMatch(/"decision":"GO"/);
    expect(writes[0][1].endsWith('\n')).toBe(true);
  });
  it('logVerdictLine: ошибка fs проглатывается (best-effort, не бросает)', () => {
    const fsImpl = { appendFileSync: () => { throw new Error('io'); }, mkdirSync: () => {} };
    expect(() => logVerdictLine({ decision: 'GO' }, { fsImpl, dir: '/r' })).not.toThrow();
  });
});
```

- [ ] **Step 2: Запустить — падает**

Expected: FAIL — функции не экспортированы.

- [ ] **Step 3: Реализовать минимум**

```js
/** Построить запись вердикта (J8) через orchestrator.logVerdict (журнал инъектируем — entry-only). */
export function buildVerdictEntry(judgeResult, nowMs) {
  return logVerdict({ verdict: judgeResult.verdict || judgeResult, nowMs });
}

/** Best-effort append-only лог вердиктов в ~/.claude/runtime/judge-verdicts.jsonl (Node fs, не Write-tool). */
export function logVerdictLine(entry, { fsImpl = fsDefault, dir = runtimeDir() } = {}) {
  try {
    fsImpl.mkdirSync(dir, { recursive: true });
    fsImpl.appendFileSync(join(dir, 'judge-verdicts.jsonl'), JSON.stringify(entry) + '\n');
  } catch { /* shadow-лог best-effort */ }
}
```

(`logVerdict({verdict, nowMs})` без `journal` возвращает `entry` и не пишет — мы используем его как чистый билдер записи.)

- [ ] **Step 4: Запустить — зелёный**

Expected: PASS.

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): shadow verdict log (J8)"
```

---

## Task 6: `runJudgeTurn` + `main()` — три режима (inert/shadow/live-block)

**Files:**
- Modify: `tools/enforce-judge-gate.mjs` (новая `runJudgeTurn`, переписать `main`)
- Test: `tools/enforce-judge-gate.test.mjs`

- [ ] **Step 1: Написать падающий тест** на чистую `runJudgeTurn` (вся логика режима без stdin/exit)

```js
import { runJudgeTurn } from './enforce-judge-gate.mjs';

describe('runJudgeTurn — режим-aware (без I/O)', () => {
  const planEv = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n## Цель\nцель\nшаг' } };
  const okText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [] });
  const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] });
  const baseDeps = (mode, text) => ({ mode, judgeActiveImpl: () => mode !== 'inert', apiKey: 'K', transport: async () => text, logged: [], logImpl: (e) => baseDeps._L.push(e) });

  it('inert → allow, прогона нет, лога нет', async () => {
    const logged = [];
    const r = await runJudgeTurn(planEv, { mode: 'inert', judgeActiveImpl: () => false, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
    expect(r.block).toBe(false);
    expect(logged).toHaveLength(0);
  });
  it('shadow + план → прогон + лог, allow (D28 не блокирует)', async () => {
    const logged = [];
    const r = await runJudgeTurn(planEv, { mode: 'shadow', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: (e) => logged.push(e) });
    expect(r.block).toBe(false);          // shadow никогда не блокирует
    expect(logged).toHaveLength(1);       // вердикт залогирован
    expect(logged[0].decision).toBe('NO-GO');
  });
  it('live-block + GO → allow + лог', async () => {
    const logged = [];
    const r = await runJudgeTurn(planEv, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
    expect(r.block).toBe(false);
    expect(logged).toHaveLength(1);
  });
  it('live-block + NO-GO → block', async () => {
    const r = await runJudgeTurn(planEv, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} });
    expect(r.block).toBe(true);
  });
  it('live-block + не-план → allow (нечего судить, $0)', async () => {
    const r = await runJudgeTurn({ tool_name: 'Bash', tool_input: { command: 'ls' } }, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: () => {} });
    expect(r.block).toBe(false);
  });
});
```

- [ ] **Step 2: Запустить — падает**

Expected: FAIL — `runJudgeTurn` не экспортирована.

- [ ] **Step 3: Реализовать `runJudgeTurn` + переписать `main`**

```js
/**
 * Чистая режим-логика хода (без stdin/exit): inert → allow; shadow → прогон+лог+allow (D28);
 * live-block → прогон+лог+finalGate. Все инъекции (judgeActiveImpl/apiKey/transport/logImpl/nowMs)
 * прокидываются в runJudgeGate. floorBlocked:false (пол подключает владелец позже — как сейчас).
 */
export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, nowMs, ...deps } = {}) {
  if (mode === 'inert') return { block: false };
  let verdict;
  try {
    verdict = await runJudgeGate(event, deps);
  } catch {
    // live-block: ошибка прогона → fail-closed block; shadow: никогда не блокирует.
    return { block: mode === 'live-block' };
  }
  if (verdict && verdict.wired) {
    try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ }
  }
  if (mode === 'shadow') return { block: false };       // D28: логирует, не блокирует
  const d = decide({ mode, verdict, floorBlocked: false });
  return { block: d.block, message: d.message };
}
```

Переписать `main()`:

```js
async function main() {
  let event, mode;
  try {
    event = parseEventJson(await readStdin());
    mode = judgeGateMode();
  } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe allow ($0)
  let result;
  try {
    result = await runJudgeTurn(event, { mode });
  } catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-closed только в live-block
  if (result.block) {
    exitDecision({ block: true, message: result.message || '[judge-gate] block' });
  } else {
    exitDecision({ block: false });
  }
}
```

(При live-block `runJudgeTurn` уже вернул block-решение; `exitDisciplineDecision` больше не нужен — fail-closed зашит в `runJudgeTurn`/`runJudgeGate` через try/catch → block в live-block. Если `exitDisciplineDecision` остаётся импортирован но не используется — убрать из импорта во избежание линт-шума.)

- [ ] **Step 4: Запустить — зелёный (вся регрессия файла)**

Run: `... enforce-judge-gate --reporter dot`
Expected: PASS — все блоки (parse / extract / callModel / runJudgeGate / log / runJudgeTurn / decide).

- [ ] **Step 5: Коммит**

```
git -C "<wt>" add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git -C "<wt>" commit -m "feat(judge-gate): runJudgeTurn three-mode wiring + main"
```

---

## Task 7: Полная регрессия tools-only + verification

**Files:** —

- [ ] **Step 1: Полная регрессия tools-only**

Run (Bash tool, фильтр убран):
```
npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" --reporter dot
```
Expected: GREEN, 0 failed. Прежний baseline ~3292 passed / 2 skipped — новый ≥ baseline + новые тесты, 0 регрессий.

- [ ] **Step 2: Спенд-инвариант (ручная сверка)**

Подтвердить по тестам Task 3/4/6: без `apiKey`/без флага транспорт-мок не вызывается (счётчик 0) ни в одной ветке. По умолчанию (нет env-флага/ключа) `judgeGateMode()==='inert'` → `runJudgeTurn` сразу allow без прогона → обёртка инертна, $0.

- [ ] **Step 3: verification-before-completion**

Свести: что заявлено (3 режима, спенд-гейт, fail-closed парс, Гейт-2 детект, shadow-лог) ↔ что покрыто тестами verbatim-выводом. Никаких claim'ов без зелёного прогона.

- [ ] **Step 4: Финальный статус**

Сообщить владельцу: A1-код готов (за флагом, $0, инертен до его рубильника), регрессия GREEN, commit-not-push. Рубильники (флаг/ключ/регистрация обёртки/shadow→block) — его шаг по runbook A1.

---

## Self-Review (writing-plans)

**Spec coverage:** §3 находка shadow → Task 6 (runJudgeTurn shadow прогоняет+логирует). §4.1 extract → Task 2. §4.2 транспорт fail-closed → Task 1 (parse) + Task 3 (callJudgeModel). §4.3 спенд-гейт → Task 3+4 (apiKey/judgeActive). §4.4 main три режима → Task 6. §5 поток → Task 6. §6 ошибки → Task 3 (transport throw→{}) + Task 6 (live-block fail-closed). §7 TDD + спенд-инвариант → все Task + Task 7. §8 границы (движок/пол/settings не трогаем) → соблюдено (только enforce-judge-gate.mjs + его тест).

**Placeholder scan:** `<wt>` = `c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo` (литеральный путь в git-командах; PowerShell, `git -C "<wt>"`). Прочих плейсхолдеров нет — весь код приведён.

**Type consistency:** `runJudgeGate(event, deps)` возвращает `{decision, wired, verdict?}`; `runJudgeTurn` читает `verdict.wired` и зовёт `buildVerdictEntry(verdict, nowMs)`; `buildVerdictEntry` читает `judgeResult.verdict` (есть при wired:true). `requiredLensesFor('gate2')` = 5 линз — тесты подают все 5 слотов. `decide({mode, verdict, floorBlocked})` — контракт существующих тестов сохранён (Task 6 их не ломает). Имена функций едины во всех тасках.

---

## Поправки после pre-code adversarial-цепочки (применять ПОВЕРХ Task 2/3/4/5/6)

Спека §9 (audit-context → sharp-edges → variant-analysis vs живой v4-судья). Дельты:

### Δ-A (Task 2 — детектор только на `Write`)

`PLAN_TOOLS` сужается до `Write` (Гейт-2 судит полный план; Edit/MultiEdit несут только фрагмент):

```js
const PLAN_TOOLS = new Set(['Write']); // SE-FIX-2: только полный content; Edit/MultiEdit — фрагмент, не судим
```

`product = String(input.content ?? '').trim();` (без `new_string`).
**Тесты Task 2 правятся:** «Edit плана → product=new_string» → **«Edit плана → shouldJudge:false»**;
добавить «MultiEdit плана → shouldJudge:false». Кейсы Write/путь/слэши/не-план — без изменений.

### Δ-B (Task 3 — `callJudgeModel`: unavailable ≠ malformed)

`callJudgeModel` различает «не смог запуститься» (degraded) и «запустился, но мусор» (NO-GO):

```js
export async function callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) {
  if (!apiKey) return { unavailable: true };                 // нет транспорт-ключа → судья недоступен ($0)
  const base = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
  const prompt = { system: base.system + '\n' + JSON_DIRECTIVE, user: base.user };
  try {
    const text = await transport(prompt, { apiKey, model });
    return parseJudgeResponse(text);                         // ran: валидный → {slots,...}; мусор → {} (NO-GO)
  } catch {
    return { unavailable: true };                            // транспорт бросил → недоступен (не NO-GO)
  }
}
```

**Тест Task 3 правится:** «нет apiKey → транспорт не зовётся, `{}`» → **«нет apiKey → `{unavailable:true}`,
транспорт не зовётся»**; «транспорт бросил → `{}`» → **«транспорт бросил → `{unavailable:true}`»**.
Кейс «валидный JSON → распарсен» — без изменений.

### Δ-C (Task 4 — `runJudgeGate`: unavailable → degraded ALLOW, не NO-GO)

После `callJudgeModel`, перед `runJudge`:

```js
  const raw = await callJudgeModel({ ... });
  if (raw && raw.unavailable) {
    return { decision: 'GO', wired: false, unavailable: true };  // VA-FIX-1: судья недоступен → degraded allow (+WARN в main)
  }
  const verdict = runJudge({ ... llmCall: () => raw, ... });
  return { decision: verdict.decision, wired: true, verdict: { ...verdict, functionName: 'gate2' } };
```

**Тест Task 4 правится:** «нет ROUTER_LLM_KEY → вердикт NO-GO» → **«нет ROUTER_LLM_KEY → `wired:false`,
`unavailable:true`, `decision:'GO'`, транспорт не зовётся»**. Кейсы «не активен», «не план», «чистый
вердикт → GO», «якорное NO → NO-GO» — без изменений.

### Δ-D (Task 5/6 — лог только реального вердикта + main гибрид)

`buildVerdictEntry`/`logVerdictLine` зовутся **только при реальном вердикте** (`wired === true`).
При `unavailable` (degraded-allow) — НЕ verdict-запись, а отдельная WARN-строка (best-effort):

```js
export function warnJudgeUnavailable(event, { fsImpl = fsDefault, dir = runtimeDir() } = {}) {
  try {
    fsImpl.mkdirSync(dir, { recursive: true });
    fsImpl.appendFileSync(join(dir, 'judge-verdicts.jsonl'),
      JSON.stringify({ kind: 'judge_unavailable', at: null, note: 'нет ROUTER_LLM_KEY или транспорт недоступен' }) + '\n');
  } catch { /* best-effort */ }
}
```

`runJudgeTurn` (Task 6) — лог-ветвление + main через `exitDisciplineDecision` (async-aware, fail-CLOSE
только на throw/malformed; MUST-VERIFY-1 verified — `disciplineOutcome` await'ит санк):

```js
export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warnImpl = warnJudgeUnavailable, nowMs, ...deps } = {}) {
  if (mode === 'inert') return { block: false };
  let verdict;
  try { verdict = await runJudgeGate(event, deps); }
  catch { return { block: mode === 'live-block' }; }          // истинный баг → live-block fail-CLOSE; shadow allow
  if (verdict && verdict.wired) {                             // реальный вердикт
    try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ }
  } else if (verdict && verdict.unavailable) {                // судья недоступен → WARN, не verdict
    try { warnImpl(event); } catch { /* best-effort */ }
  }
  if (mode === 'shadow') return { block: false };             // D28: всегда allow
  const d = decide({ mode, verdict, floorBlocked: false });   // live-block: degraded-GO → allow; реальный NO-GO → block
  return { block: d.block, message: d.message };
}
```

`main()` без изменений относительно Task 6 (он уже зовёт `runJudgeTurn` → `exitDecision`). NB: degraded-allow
проходит через `decide({mode:'live-block', verdict:{decision:'GO',...}})` → `finalGate(GO, floorBlocked:false)`
→ allow ✓. Истинный throw уже пойман в `runJudgeTurn` (live-block→block) — это и есть fail-CLOSE на баг.

**Тесты Task 6 правятся:** «live-block + нет ключа» (если присутствовал) трактуется как degraded-allow;
добавить кейс «live-block + unavailable → allow + warnImpl вызван». Кейсы inert/shadow/GO/NO-GO — без изменений.

### Δ-E (документация спенд-семантики)

В docstring шапки `enforce-judge-gate.mjs` (Task 3, при добавлении функций) зафиксировать: **inert=$0 /
shadow=метеренный / block=метеренный**; env строгие (`ENABLED` ровно `1`, `MODE` ровно `block`; опечатка
→ безопасный дефолт). Отложенные residuals (дедуп content-hash, budget-cap) — строкой «follow-up».

### Self-review поправок

Покрытие §9: п.1→Δ-A, п.2→Δ-B/Δ-C, п.3→Δ-D (main гибрид), п.4→Δ-E, п.5→Δ-D (warn-ветвь), п.6→Δ-E
(residuals строкой), п.7→подтверждено (Write-only Δ-A / engine-no-main / secure-default). Type-consistency:
`runJudgeGate` теперь возвращает `{decision,wired,verdict?,unavailable?}`; `runJudgeTurn` ветвится по
`wired`/`unavailable`; `callJudgeModel` → `{unavailable:true}|{}|{slots,objections,decision?}`. Имена едины.
