# Router discipline overhaul — Stage 3 (Enforcement) 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:** Включить **автоматическое принуждение** правильного маршрутизатора: классификатор определяет тип задачи + домен + цепочку, PreToolUse-хук блокирует Edit/Write/Bash до вызова рекомендованного навыка. Цель — поднять дисциплину с 0% (на feature/planning) до ≥75%; снять боль «не дёргаешь billing-audit/marketing/security» через доменную разметку (Task 0a) и цепочки (Task 0b).

**Architecture:** Три новых файла-хука + один классификатор + расширение существующего Stop-гейта. Двухслойный классификатор: regex по реестровым keyword'ам (быстро, дёшево) → Sonnet escalation если regex не уверен (с реестром в prompt'е). State per session в `~/.claude/runtime/router-state-<session>.json`. Откат — удаление 3 строк из `~/.claude/settings.json`.

**Tech Stack:** Node.js ESM, Vitest, fetch к Anthropic API (без SDK, built-in Node 18+), существующий реестр `docs/registry/nodes.yaml` + адаптеры этапа 2.

**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md` + amendment 2026-05-24 (Task 0a/0b + chain governance).

**Прошлые этапы:**

- Этап 1 ✅ закрыт 2026-05-23 (реестр 83 узла + 16 chains).
- Этап 2 ✅ закрыт 2026-05-24 (3 среза измерений, baseline зафиксирован, классификация-map переключена на реестр).

---

## Pre-flight

- [ ] **Step 1: Pre-flight sync с origin/main**

```bash
git fetch origin
git log HEAD..origin/main --oneline
```

Этап 3 НЕ правит нормативку из списка Pravila §15.2 (Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql). Pre-flight только для информации — origin/main мог уйти вперёд. Если уйдёт — наша feature-branch остаётся независимой.

- [ ] **Step 2: Worktree от свежего origin/main**

```bash
# Из main checkout:
EnterWorktree name=router-stage3-enforcement
# или fallback git worktree если EnterWorktree недоступен:
git fetch origin
git worktree add .claude/worktrees/router-stage3-enforcement -b feat/router-overhaul-stage-3-enforcement origin/main
```

Pavila §15.1 — субагенты Sonnet/Opus only; git-commit-tasks НЕ Haiku.

- [ ] **Step 3: Junction зависимостей (Windows quirk #108)**

```powershell
$wt = "c:\моя\проекты\портал crm\Документация\.claude\worktrees\router-stage3-enforcement"
$main = "c:\моя\проекты\портал crm\Документация"
New-Item -ItemType Junction -Path "$wt\node_modules" -Target "$main\node_modules" -Force
New-Item -ItemType Junction -Path "$wt\app\node_modules" -Target "$main\app\node_modules" -Force
# bin if exists:
if (Test-Path "$wt\bin") { Remove-Item "$wt\bin" -Recurse -Force }
cmd /c "mklink /J `"$wt\bin`" `"$main\bin`""
```

- [ ] **Step 4: Зелёный baseline регрессии**

```bash
cd "<worktree>/app" && npx vitest run --config vitest.config.tools.mjs
```

Expected: **24 файла / 456 тестов GREEN** (унаследовано с этапа 2).

- [ ] **Step 5: Sanity-check Anthropic API access**

```bash
node -e 'const r = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": process.env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json" }, body: JSON.stringify({ model: "claude-haiku-4-5-20251001", max_tokens: 50, messages: [{role:"user", content:"reply with just OK"}] }) }); console.log(r.status, await r.text())'
```

Expected: `200 ...{"content":[{"text":"OK"...`. Если 401 — ENV var отсутствует, ОБРАТИТЬСЯ К ЗАКАЗЧИКУ за ключом перед стартом.

---

## File Structure

**Создаём:**

- `tools/router-classifier.mjs` — pure module (regex Layer 1 + LLM Layer 2 + cache + budget guard).
- `tools/router-classifier.test.mjs` — unit-тесты обоих слоёв.
- `tools/router-prehook.mjs` — UserPromptSubmit hook, вызывает classifier и пишет state.
- `tools/router-prehook.test.mjs` — тесты state-формата.
- `tools/router-tool-gate.mjs` — PreToolUse hook, читает state и блокирует Edit/Write/Bash при нарушении.
- `tools/router-tool-gate.test.mjs` — тесты блокировок и escape hatch.
- `tools/router-test-prompts.json` — 20 заготовленных тестовых промптов с ground truth для accuracy-замера.
- `tools/router-accuracy-runner.mjs` — pure script: прогоняет 20 промптов через classifier, выдаёт accuracy report.

**Модифицируем:**

- `docs/registry/nodes.yaml` — добавить keyword-триггеры на 30+ доменных скилов (Task 0a Step A).
- `tools/observer-stop-hook.mjs` — добавить обновление `chain_progress` в state-файле + запись в эпизод (Task 7 chain tracking).
- `tools/brain-retro-analyzer.mjs` — две новых оси (domain-hit-rate + chain-completion-rate).
- `tools/brain-retro-analyzer.test.mjs` — тесты на новые оси.
- `tools/status-md-generator.mjs` — две новых строки в блоке «Метрики дисциплины».
- `tools/status-md-generator.test.mjs` — тесты на строки.
- `.claude/settings.json` — регистрация трёх новых хуков.
- `docs/observer/active-projects.md` + memory `project_router_overhaul.md` (continuity).

**Не трогаем (это этап 4):**

- Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR.
- `docs/router-procedure.md` (v1.4 → v2.0 — этап 4).
- Существующие economy/skill-discipline хуки (трогаются ТОЛЬКО для совместимости-проверок).

---

## Phase A — Classifier без enforcement (Tasks 1-5)

После Phase A классификатор работает и пишет state, но **никаких блокировок** — гейт ещё не зарегистрирован в settings.json. Это даёт нам safety: можно прогнать accuracy на 20 промптах и реальной сессии БЕЗ риска сломать рабочий процесс.

### Task 1: Доменные keyword-триггеры в реестре (Task 0a часть 1)

**Зачем.** Layer 1 regex ищет совпадения keyword'ов узла с промптом. Без доменных keyword'ов на специализированных скилах (#62 billing-audit, #71 pdn-152fz-audit, #74 marketing и т. д.) regex не выберет правильный узел.

**Files:**

- Modify: `docs/registry/nodes.yaml`

**Маппинг доменных keyword'ов** (минимум 5 на узел, всего ~30 узлов):

| Узел | Доменные keyword'ы |
|---|---|
| #62 billing-audit | списание, биллинг, тариф, баланс, начисление лида, lead_charges, копейки, идемпотентность, csv reconcile, bcmath, bcadd |
| #63 ru-tax-accounting | РСБУ, НДС, УСН, налоговая база, налог на прибыль, выручка, проводка, дт/кт, бухгалтер |
| #61 finance | reconciliation, variance, journal entry, financial statements, audit support, close management |
| #66 laravel-backend-patterns | controller, service, job, eloquent, RLS, partition, lockForUpdate, dispatch |
| #64 rector | автоматический рефакторинг, версия PHP, deprecated PHP, code modernization |
| #65 php-insights | метрики качества кода, complexity, architecture metrics |
| #43 deptrac | архитектурная зависимость, layer dependency, граница слоёв |
| #25 semgrep | SAST, статический анализ, sast scan, secret pattern |
| #39 trail-of-bits | глубокий security audit, supply chain risk, audit context |
| #40 security-guidance | inline уязвимость, code security warning |
| #68 zap | DAST, scan running portal, проникновение в работающий портал |
| #69 nuclei | nuclei, уязвимость по шаблону, cve scan |
| #70 ward | laravel security config, env audit, secrets config |
| #71 pdn-152fz-audit | ПДн, персональные данные, 152-ФЗ, согласие на обработку, телефон лида, маскирование, pd_subject_request |
| #72 threat-model | STRIDE, моделирование угроз, attack surface, точки входа |
| #73 security-go-live | go-live, выход в интернет, публикация в прод, security release gate |
| #74 marketing | email-рассылка, кампания, контент, лендинг, реклама, лидген, вебинар |
| #75 marketingskills | маркетинговая фреймворк, AIDA, PAS, FAB, USP |
| #76 brand-voice | тон бренда, voice, тональность, позиционирование |
| #77 marketing-ru | РФ-канал, яндекс, вконтакте, telegram-канал, unisender, российский рынок |
| #78 metrika-mcp | яндекс.метрика, веб-аналитика, статистика посещений |
| #79 wordstat-mcp | ключевые слова, wordstat, поисковые запросы |
| #80 telegram-mcp | telegram, постинг в канал, telegram-бот |
| #81 postiz | smm-планировщик, постинг в соцсети |
| #51 operations | бизнес-процесс документ, runbook, capacity plan, risk assessment |
| #52 process-modeling | BPMN, моделирование процесса, swimlane |
| #53 process-analysis | discovery процесса, узкое место, bottleneck |
| #55 discovery-interview | discovery, интервью заказчика, JTBD |
| #36 adr-kit | architecture decision record, ADR, архитектурное решение |
| #37 mermaid | mermaid диаграмма, C4 диаграмма |
| #38 architecture-patterns | clean architecture, hexagonal, DDD |
| #47 openapi-mcp | OpenAPI, swagger, спека API |
| #48 promptfoo | eval промпта, LLM test |
| #49 data-scientist | ML модель, статистика, корреляция |
| #56 skill-creator | создать скил, новый skill |
| #57 plugin-dev | плагин Claude Code, plugin.json |
| #58 hookify | хук Claude, новый hook |
| #60 context7 | актуальная документация библиотеки, лайвдоки |

- [ ] **Step 1: Прочитать формат узла #62 в реестре**

Открыть `docs/registry/nodes.yaml`, найти `- id: "#62"`, посмотреть текущий блок `triggers:`.

- [ ] **Step 2: Добавить keyword-триггеры на все ~30 узлов из таблицы**

Для каждого узла дополнить `triggers:` строками вида:

```yaml
      - {keyword: "списание", weight: 1.0}
      - {keyword: "биллинг", weight: 1.0}
      # ... по таблице минимум 5-10 keyword'ов
```

Не удалять существующие keyword/classification триггеры. Все ~30 узлов править в ОДНОМ редактировании.

- [ ] **Step 3: Валидировать YAML**

```bash
cd app && node -e 'import("../tools/registry-load.mjs").then(m=>{const r=m.loadRegistry({useCache:false});console.log("nodes:",r.nodes.length,"chains:",Object.keys(r.chains).length)})'
```

Expected: `nodes: 83 chains: 16` без schema violations.

- [ ] **Step 4: Прогнать registry-load и registry-render тесты**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/registry-load.test.mjs ../tools/registry-render.test.mjs
```

Expected: GREEN.

- [ ] **Step 5: Проверить auto-render**

```bash
node tools/registry-render.mjs --check
```

Expected: ✅ ok на обоих файлах. Если есть diff — auto-rendered, закоммитим вместе.

- [ ] **Step 6: Commit**

```bash
git add docs/registry/nodes.yaml docs/Tooling_v8_3.md docs/routing-off-phase.md
git commit -m "feat(registry): keyword триггеры на 30+ доменных скилах (stage 3 task 1)

Task 0a — доменная разметка. Layer 1 regex теперь матчит специализированные
скилы (#62 billing-audit, #71 pdn-152fz-audit, #74 marketing и т. д.) по
доменным словам в промпте, не только по типу задачи.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: Router-classifier Layer 1 (pure regex)

**Цель.** Pure-функция, которая по тексту промпта возвращает `{taskType, micro, recommendedNode, source: 'regex'}`. Layer 2 (LLM) — отдельно в Task 3.

**Files:**

- Create: `tools/router-classifier.mjs`
- Test: `tools/router-classifier.test.mjs`

- [ ] **Step 1: Failing test — basic classification**

`tools/router-classifier.test.mjs`:

```javascript
import { describe, it, expect, beforeEach } from 'vitest';
import { classifyByRegex } from './router-classifier.mjs';

const fakeRegistry = {
  nodes: [
    { id: '#19', name: 'Superpowers', status: 'active', triggers: [
      { classification: 'feature', weight: 1.0 },
      { classification: 'planning', weight: 1.0 },
    ] },
    { id: '#62', name: 'billing-audit', status: 'active', triggers: [
      { keyword: 'списание', weight: 1.0 },
      { keyword: 'биллинг', weight: 1.0 },
      { classification: 'bugfix', weight: 0.5 },
    ] },
    { id: '#74', name: 'marketing', status: 'active', triggers: [
      { keyword: 'email-рассылка', weight: 1.0 },
      { keyword: 'кампания', weight: 1.0 },
      { classification: 'marketing', weight: 1.0 },
    ] },
    { id: '#11', name: 'pint', status: 'active', triggers: [
      { classification: 'refactor', weight: 1.0 },
      { classification: 'cleanup', weight: 1.0 },
    ] },
  ],
};

describe('classifyByRegex — task type', () => {
  it('detects feature from RU keyword «фича»', () => {
    const r = classifyByRegex('давай сделаем новую фичу для биллинга', fakeRegistry);
    expect(r.taskType).toBe('feature');
  });

  it('detects planning from RU «план»', () => {
    const r = classifyByRegex('напиши план рефакторинга модуля X', fakeRegistry);
    expect(r.taskType).toBe('planning');
  });

  it('detects bugfix from EN «bug»', () => {
    const r = classifyByRegex('there is a bug in the auth flow', fakeRegistry);
    expect(r.taskType).toBe('bugfix');
  });

  it('detects micro for typo', () => {
    const r = classifyByRegex('опечатка в файле X', fakeRegistry);
    expect(r.micro).toBe(true);
  });

  it('detects micro for rename', () => {
    const r = classifyByRegex('переименуй функцию foo в bar', fakeRegistry);
    expect(r.micro).toBe(true);
  });

  it('returns taskType=unknown when no signal', () => {
    const r = classifyByRegex('просто привет', fakeRegistry);
    expect(r.taskType).toBe('unknown');
    expect(r.micro).toBe(false);
  });
});

describe('classifyByRegex — domain node match', () => {
  it('picks #62 billing-audit on «списание»', () => {
    const r = classifyByRegex('почини двойное списание лида', fakeRegistry);
    expect(r.recommendedNode).toBe('#62');
  });

  it('picks #74 marketing on «email-рассылка»', () => {
    const r = classifyByRegex('составь email-рассылку для тарифа Бизнес', fakeRegistry);
    expect(r.recommendedNode).toBe('#74');
  });

  it('falls back to classification trigger when no keyword match', () => {
    const r = classifyByRegex('рефакторинг кода', fakeRegistry);
    // 'рефакторинг' → classification: refactor → #11 pint
    expect(r.recommendedNode).toBe('#11');
  });

  it('returns null when no node matched', () => {
    const r = classifyByRegex('просто вопрос', fakeRegistry);
    expect(r.recommendedNode).toBeNull();
  });

  it('case-insensitive keyword match', () => {
    const r = classifyByRegex('СПИСАНИЕ дублируется', fakeRegistry);
    expect(r.recommendedNode).toBe('#62');
  });
});

describe('classifyByRegex — source tag', () => {
  it('always marks source: regex', () => {
    const r = classifyByRegex('test', fakeRegistry);
    expect(r.source).toBe('regex');
  });
});

describe('classifyByRegex — confidence', () => {
  it('returns confidence>=0.8 for clean keyword match', () => {
    const r = classifyByRegex('списание дублируется', fakeRegistry);
    expect(r.confidence).toBeGreaterThanOrEqual(0.8);
  });

  it('returns confidence<0.5 when ambiguous (no clean match)', () => {
    const r = classifyByRegex('что-то непонятное', fakeRegistry);
    expect(r.confidence).toBeLessThan(0.5);
  });
});
```

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

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-classifier.test.mjs
```

Expected: `Cannot find module './router-classifier.mjs'`.

- [ ] **Step 3: Реализовать classifyByRegex в `tools/router-classifier.mjs`**

```javascript
#!/usr/bin/env node
/**
 * Router classifier — pure regex Layer 1 + LLM Layer 2 (escalation).
 * Stage 3 of router discipline overhaul.
 *
 * Layer 1: regex по реестровым keyword/classification триггерам активных узлов.
 * Возвращает { taskType, micro, recommendedNode, confidence, source: 'regex' }.
 *
 * Layer 2 (см. classifyByLLM): Sonnet с реестром в prompt'е.
 *
 * Pure (Layer 1): read-only, никакого fs/exec/net. Caller передаёт registry.
 */

const TASK_TYPE_KEYWORDS = {
  feature: ['фич', 'feature', 'новый функционал', 'add feature'],
  planning: ['план', 'plan', 'спека', 'spec', 'roadmap'],
  bugfix: ['баг', 'bug', 'дебаг', 'debug', 'почини', 'fix', 'ошибк', 'не работает'],
  refactor: ['рефактор', 'refactor', 'почисти код', 'упрости'],
  cleanup: ['уберём', 'удали', 'remove', 'cleanup', 'dead code'],
  marketing: ['маркетинг', 'marketing', 'кампани', 'лендинг'],
  security: ['безопасност', 'security', 'уязвимост', 'vulnerability'],
  analysis: ['проанализируй', 'analysis', 'разбер', 'investigate'],
  monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'],
  'memory-sync': ['запомни', 'обнови память', 'memory', 'CLAUDE.md', 'MEMORY.md'],
  question: ['что такое', 'как работает', 'почему', 'объясни', 'расскажи'],
};

const MICRO_KEYWORDS = [
  'опечатк', 'typo',
  'переименуй', 'rename',
  'удали мёртв', 'dead code',
  'формат', 'format',
  'константу', 'one constant',
];

function lower(s) { return String(s || '').toLowerCase(); }

function detectTaskType(prompt) {
  const p = lower(prompt);
  for (const [t, kws] of Object.entries(TASK_TYPE_KEYWORDS)) {
    for (const kw of kws) {
      if (p.includes(kw)) return t;
    }
  }
  return 'unknown';
}

function detectMicro(prompt) {
  const p = lower(prompt);
  return MICRO_KEYWORDS.some((kw) => p.includes(kw));
}

function detectRecommendedNode(prompt, registry) {
  const p = lower(prompt);
  let best = { id: null, weight: 0 };

  for (const node of registry.nodes || []) {
    if (node.status !== 'active') continue;
    for (const t of node.triggers || []) {
      const w = t.weight ?? 1.0;
      if (t.keyword) {
        if (p.includes(lower(t.keyword)) && w > best.weight) {
          best = { id: node.id, weight: w };
        }
      } else if (t.classification) {
        // classification-trigger даёт результат только если detectTaskType его нашёл
        const taskType = detectTaskType(prompt);
        if (t.classification === taskType && w > best.weight) {
          best = { id: node.id, weight: w };
        }
      }
    }
  }
  return best.id;
}

function computeConfidence(taskType, recommendedNode, prompt) {
  if (recommendedNode === null && taskType === 'unknown') return 0.1;
  if (recommendedNode === null) return 0.4;
  if (taskType === 'unknown') return 0.5;
  // Keyword match даёт high confidence; classification-only — medium.
  const p = lower(prompt);
  const hasHardKeyword = p.length > 0 && (
    p.includes('списан') || p.includes('биллинг') || p.includes('маркетинг') ||
    p.includes('email-рассылк') || p.includes('152-фз') || p.includes('go-live') ||
    p.includes('фич') || p.includes('план') || p.includes('баг')
  );
  return hasHardKeyword ? 0.9 : 0.7;
}

export function classifyByRegex(prompt, registry) {
  const taskType = detectTaskType(prompt);
  const micro = detectMicro(prompt);
  const recommendedNode = detectRecommendedNode(prompt, registry);
  const confidence = computeConfidence(taskType, recommendedNode, prompt);
  return { taskType, micro, recommendedNode, confidence, source: 'regex' };
}
```

- [ ] **Step 4: Run — GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-classifier.test.mjs
```

Expected: ВСЕ 14 тестов PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/router-classifier.mjs tools/router-classifier.test.mjs
git commit -m "feat(router): classifier Layer 1 — pure regex по реестру (stage 3 task 2)

classifyByRegex(prompt, registry) → {taskType, micro, recommendedNode, confidence, source}.
Read-only, без fs/exec/net. RU+EN keyword'ы для типа задачи + детект micro
+ матч по keyword/classification триггерам активных узлов реестра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: Router-classifier Layer 2 (Sonnet escalation) + cache + budget

**Зачем.** Если Layer 1 confidence < 0.7 — эскалируем в Sonnet с реестром в prompt'е. Кэш per-prompt-hash + бюджет ≤200 вызовов/день.

**Files:**

- Modify: `tools/router-classifier.mjs` (добавить exports `classifyByLLM` + `classify`)
- Modify: `tools/router-classifier.test.mjs` (новые describe)

- [ ] **Step 1: Failing tests**

В `tools/router-classifier.test.mjs` добавить describe-блок:

```javascript
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify } from './router-classifier.mjs';

describe('buildLLMPrompt', () => {
  it('serializes active nodes with id+name+top-3 triggers', () => {
    const prompt = buildLLMPrompt('почини списание', fakeRegistry);
    expect(prompt).toMatch(/#62/);
    expect(prompt).toMatch(/billing-audit/);
    expect(prompt).toMatch(/списание/);
    expect(prompt).toMatch(/почини списание/);
  });

  it('excludes inactive nodes', () => {
    const reg = { nodes: [...fakeRegistry.nodes, { id: '#999', name: 'gone', status: 'historic', triggers: [] }] };
    const prompt = buildLLMPrompt('test', reg);
    expect(prompt).not.toMatch(/#999/);
  });
});

describe('parseLLMResponse', () => {
  it('parses JSON object', () => {
    const r = parseLLMResponse('{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword списание"}');
    expect(r.taskType).toBe('bugfix');
    expect(r.recommendedNode).toBe('#62');
    expect(r.confidence).toBe(0.9);
  });

  it('parses JSON wrapped in ```json``` block', () => {
    const r = parseLLMResponse('```json\n{"taskType":"feature","micro":false,"recommendedNode":"#19","confidence":0.8}\n```');
    expect(r.taskType).toBe('feature');
  });

  it('returns null on unparseable response', () => {
    expect(parseLLMResponse('I cannot help with this')).toBeNull();
  });
});

describe('shouldEscalate', () => {
  it('escalates when confidence < 0.7', () => {
    expect(shouldEscalate({ confidence: 0.6, taskType: 'bugfix' })).toBe(true);
  });

  it('does NOT escalate on micro', () => {
    expect(shouldEscalate({ confidence: 0.4, taskType: 'unknown', micro: true })).toBe(false);
  });

  it('does NOT escalate when confidence >= 0.7', () => {
    expect(shouldEscalate({ confidence: 0.9, taskType: 'bugfix' })).toBe(false);
  });
});

describe('classify — full integration (with mock LLM)', () => {
  it('returns regex result when confidence high', async () => {
    const r = await classify('почини списание дублируется', fakeRegistry, { llmCall: () => { throw new Error('should not call LLM'); } });
    expect(r.source).toBe('regex');
    expect(r.recommendedNode).toBe('#62');
  });

  it('escalates to LLM when confidence low', async () => {
    const r = await classify('что-то непонятное', fakeRegistry, {
      llmCall: async () => ({ taskType: 'question', micro: false, recommendedNode: null, confidence: 0.95, recommendedChain: null })
    });
    expect(r.source).toBe('llm');
    expect(r.taskType).toBe('question');
  });

  it('uses cache on second call with same prompt', async () => {
    let calls = 0;
    const llmCall = async () => { calls++; return { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, recommendedChain: 'L1' }; };
    const cache = new Map();
    await classify('ambiguous query', fakeRegistry, { llmCall, cache });
    await classify('ambiguous query', fakeRegistry, { llmCall, cache });
    expect(calls).toBe(1); // Second hit cache.
  });
});
```

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

Expected: новые тесты FAIL `not a function`, существующие 14 PASS.

- [ ] **Step 3: Добавить функции в `tools/router-classifier.mjs`**

В конец файла:

```javascript
const LLM_SYSTEM_PROMPT = `You are a router classifier for an AI coding assistant. Given a user prompt and a registry of available skills/tools (nodes), choose:
- taskType: one of {feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown}
- micro: true if the task is a tiny edit (≤2 files, ≤20 lines, e.g. typo / rename / single constant)
- recommendedNode: id of the single best-matching active node, or null if nothing matches
- confidence: 0.0-1.0
- recommendedChain: id of the chain (L1-L16) if the task fits a known chain, else null
- reasoning: 1-2 sentences why

Reply with ONLY a JSON object, no prose. Example:
{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword 'списание' matches #62 billing-audit"}`;

export function buildLLMPrompt(prompt, registry) {
  const nodes = (registry.nodes || []).filter((n) => n.status === 'active');
  const nodeLines = nodes.map((n) => {
    const triggers = (n.triggers || [])
      .slice(0, 3)
      .map((t) => t.keyword || `cls:${t.classification}`)
      .filter(Boolean)
      .join(', ');
    return `- ${n.id} ${n.name} [${triggers}]`;
  }).join('\n');

  const chains = Object.entries(registry.chains || {})
    .map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
    .join('\n');

  return `${LLM_SYSTEM_PROMPT}

## Available nodes
${nodeLines}

## Available chains
${chains}

## User prompt
${prompt}

Reply with JSON object only.`;
}

export function parseLLMResponse(text) {
  if (!text) return null;
  const trimmed = String(text).trim();
  // Strip ```json``` wrapper if present
  const stripped = trimmed.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim();
  try {
    const parsed = JSON.parse(stripped);
    if (typeof parsed.taskType !== 'string') return null;
    return parsed;
  } catch {
    return null;
  }
}

export function shouldEscalate(regexResult) {
  if (regexResult.micro) return false;
  if (regexResult.confidence >= 0.7) return false;
  return true;
}

export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4-5-20251001', fetchImpl = fetch }) {
  const r = await fetchImpl('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': apiKey,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      model,
      max_tokens: 300,
      messages: [{ role: 'user', content: prompt }],
    }),
  });
  if (!r.ok) {
    throw new Error(`Anthropic API ${r.status}: ${await r.text()}`);
  }
  const data = await r.json();
  return data.content?.[0]?.text || '';
}

function hashPrompt(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) {
    h = ((h << 5) - h) + s.charCodeAt(i);
    h |= 0;
  }
  return String(h);
}

export async function classify(prompt, registry, options = {}) {
  const regexResult = classifyByRegex(prompt, registry);
  if (!shouldEscalate(regexResult)) return regexResult;

  const cache = options.cache;
  const key = hashPrompt(prompt);
  if (cache && cache.has(key)) {
    return { ...cache.get(key), source: 'cache' };
  }

  const llmCall = options.llmCall || (async () => {
    const llmPrompt = buildLLMPrompt(prompt, registry);
    const text = await callAnthropicAPI(llmPrompt, { apiKey: process.env.ANTHROPIC_API_KEY });
    return parseLLMResponse(text);
  });

  let llmResult;
  try {
    llmResult = await llmCall();
  } catch (err) {
    // LLM-down — fallback to regex result with diagnostic flag
    return { ...regexResult, llmError: err.message };
  }

  if (!llmResult) return regexResult; // unparseable — fallback

  const finalResult = { ...llmResult, source: 'llm' };
  if (cache) cache.set(key, finalResult);
  return finalResult;
}
```

- [ ] **Step 4: Run — GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-classifier.test.mjs
```

Expected: все ~25 тестов PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/router-classifier.mjs tools/router-classifier.test.mjs
git commit -m "feat(router): classifier Layer 2 — Sonnet escalation + cache (stage 3 task 3)

buildLLMPrompt сериализует активные узлы + chains в prompt.
classify() — гибрид regex + LLM с кэшем per-prompt-hash.
callAnthropicAPI через built-in fetch (без SDK).
shouldEscalate: confidence<0.7 AND not micro.
Fallback на regex-result при ошибке LLM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: Router-prehook — UserPromptSubmit hook + state-файл

**Цель.** Когда заказчик отправляет промпт, прехук вызывает classifier и пишет state в `~/.claude/runtime/router-state-<session>.json` для текущего хода.

**Files:**

- Create: `tools/router-prehook.mjs`
- Test: `tools/router-prehook.test.mjs`

- [ ] **Step 1: Failing test**

`tools/router-prehook.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { buildStateFromClassification, isEnforcementRequired } from './router-prehook.mjs';

describe('buildStateFromClassification', () => {
  it('builds full state object', () => {
    const cls = { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, source: 'regex', recommendedChain: 'L1' };
    const s = buildStateFromClassification(cls, { sessionId: 'abc', promptHash: '12345' });
    expect(s.sessionId).toBe('abc');
    expect(s.promptHash).toBe('12345');
    expect(s.classification).toEqual(cls);
    expect(s.skillInvokedThisTurn).toBe(false);
    expect(s.chainProgress).toEqual([]);
    expect(s.enforcementRequired).toBe(true);
    expect(s.timestamp).toBeDefined();
  });

  it('enforcementRequired false on micro', () => {
    const s = buildStateFromClassification({ taskType: 'bugfix', micro: true, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
    expect(s.enforcementRequired).toBe(false);
  });

  it('enforcementRequired false when no recommendedNode', () => {
    const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
    expect(s.enforcementRequired).toBe(false);
  });

  it('enforcementRequired false on excluded taskType', () => {
    const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: '#60' }, { sessionId: 'a', promptHash: 'b' });
    expect(s.enforcementRequired).toBe(false);
  });
});

describe('isEnforcementRequired', () => {
  it('true on feature with node', () => {
    expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: '#19' })).toBe(true);
  });

  it('false on micro', () => {
    expect(isEnforcementRequired({ taskType: 'feature', micro: true, recommendedNode: '#19' })).toBe(false);
  });

  it('false when no node', () => {
    expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: null })).toBe(false);
  });

  it('false on question/memory-sync (excluded)', () => {
    expect(isEnforcementRequired({ taskType: 'question', micro: false, recommendedNode: '#60' })).toBe(false);
    expect(isEnforcementRequired({ taskType: 'memory-sync', micro: false, recommendedNode: '#33' })).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-prehook.test.mjs
```

Expected: `Cannot find module`.

- [ ] **Step 3: Реализовать `tools/router-prehook.mjs`**

```javascript
#!/usr/bin/env node
/**
 * UserPromptSubmit hook — router prehook.
 * Stage 3 of router discipline overhaul.
 *
 * При каждом prompt'е:
 *   1. Читает реестр.
 *   2. Вызывает classifier.
 *   3. Пишет state в ~/.claude/runtime/router-state-<session>.json.
 *
 * Не блокирует prompt — только готовит state для PreToolUse gate (router-tool-gate).
 *
 * Контракт UserPromptSubmit hook (Claude Code): читает JSON из stdin
 *   { session_id, transcript_path, hook_event_name, user_prompt }
 * на stdout — { } (пустой объект = ничего не меняем в prompt'е).
 */

import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';

const ENFORCEMENT_TYPES = new Set(['feature', 'planning', 'bugfix', 'refactor', 'cleanup', 'marketing', 'security', 'analysis', 'monitoring']);

export function isEnforcementRequired(classification) {
  if (!classification) return false;
  if (classification.micro) return false;
  if (!classification.recommendedNode) return false;
  if (!ENFORCEMENT_TYPES.has(classification.taskType)) return false;
  return true;
}

function hashPrompt(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }
  return String(h);
}

export function buildStateFromClassification(classification, { sessionId, promptHash }) {
  return {
    sessionId,
    promptHash,
    classification,
    skillInvokedThisTurn: false,
    chainProgress: [],
    enforcementRequired: isEnforcementRequired(classification),
    timestamp: new Date().toISOString(),
  };
}

function stateFilePath(sessionId) {
  return join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
}

async function main() {
  let input = '';
  for await (const chunk of process.stdin) input += chunk;
  const event = JSON.parse(input || '{}');
  const sessionId = event.session_id || 'unknown';
  const userPrompt = event.user_prompt || '';

  try {
    const { loadRegistry } = await import('./registry-load.mjs');
    const { classify } = await import('./router-classifier.mjs');
    const registry = loadRegistry({ useCache: false });

    const cachePath = join(homedir(), '.claude', 'runtime', 'router-classification-cache.json');
    const cache = new Map();
    if (existsSync(cachePath)) {
      try {
        const data = JSON.parse(readFileSync(cachePath, 'utf-8'));
        for (const [k, v] of Object.entries(data)) cache.set(k, v);
      } catch { /* ignore */ }
    }

    const classification = await classify(userPrompt, registry, { cache });
    const state = buildStateFromClassification(classification, {
      sessionId,
      promptHash: hashPrompt(userPrompt),
    });

    const statePath = stateFilePath(sessionId);
    mkdirSync(dirname(statePath), { recursive: true });
    writeFileSync(statePath, JSON.stringify(state, null, 2));

    // Persist cache
    const cacheObj = {};
    for (const [k, v] of cache) cacheObj[k] = v;
    writeFileSync(cachePath, JSON.stringify(cacheObj, null, 2));

    process.stdout.write(JSON.stringify({}));
    process.exit(0);
  } catch (err) {
    // Любая ошибка прехука НЕ должна сломать prompt пользователя — fallback: пустой state, прохожу.
    process.stderr.write(`[router-prehook] ${err.message}\n`);
    process.stdout.write(JSON.stringify({}));
    process.exit(0);
  }
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
  main();
}
```

- [ ] **Step 4: Run — GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-prehook.test.mjs
```

Expected: все 8 тестов PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/router-prehook.mjs tools/router-prehook.test.mjs
git commit -m "feat(router): UserPromptSubmit hook — classifier wiring (stage 3 task 4)

При каждом prompt'е: classifier → state-файл ~/.claude/runtime/router-state-<session>.json.
isEnforcementRequired — guard: micro/question/memory-sync пропускают.
Cache per-prompt-hash в runtime/router-classification-cache.json.
Любая ошибка прехука — silent fallback, пользовательский поток не ломается.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: 20 тестовых промптов + accuracy-runner

**Цель.** Замер точности classifier'а ДО регистрации в settings.json. Decision gate Phase A.

**Files:**

- Create: `tools/router-test-prompts.json` — 20 промптов с ground truth.
- Create: `tools/router-accuracy-runner.mjs` — pure script.

- [ ] **Step 1: Создать `tools/router-test-prompts.json`**

```json
{
  "$schema_version": 1,
  "description": "Ground truth для классификатора. Stage 3 acceptance: ≥75% точности на этом наборе.",
  "prompts": [
    {"text": "сделай фичу — карточка тенанта показывает баланс в копейках", "expectedType": "feature", "expectedNode": "#19", "expectedMicro": false},
    {"text": "напиши план рефакторинга AdminTenantsController", "expectedType": "planning", "expectedNode": "#19", "expectedMicro": false},
    {"text": "почини двойное списание лида у клиента id=7", "expectedType": "bugfix", "expectedNode": "#62", "expectedMicro": false},
    {"text": "составь email-рассылку для тарифа Бизнес", "expectedType": "marketing", "expectedNode": "#74", "expectedMicro": false},
    {"text": "проверь безопасность портала перед выходом в интернет", "expectedType": "security", "expectedNode": "#73", "expectedMicro": false},
    {"text": "опечатка в imports.ts — поправь", "expectedType": "bugfix", "expectedNode": null, "expectedMicro": true},
    {"text": "переименуй функцию calc в computeBalance", "expectedType": "unknown", "expectedNode": null, "expectedMicro": true},
    {"text": "удали мёртвый код из ProjectService", "expectedType": "cleanup", "expectedNode": null, "expectedMicro": true},
    {"text": "формат-проход pint по app/", "expectedType": "refactor", "expectedNode": "#11", "expectedMicro": true},
    {"text": "что такое RLS в постгресе?", "expectedType": "question", "expectedNode": null, "expectedMicro": false},
    {"text": "обнови память по этапу 3", "expectedType": "memory-sync", "expectedNode": null, "expectedMicro": false},
    {"text": "проверь что таблица lead_charges имеет правильный индекс по tenant_id", "expectedType": "analysis", "expectedNode": "#62", "expectedMicro": false},
    {"text": "проанализируй медленный запрос /api/deals", "expectedType": "analysis", "expectedNode": null, "expectedMicro": false},
    {"text": "напиши STRIDE для админ-зоны", "expectedType": "security", "expectedNode": "#72", "expectedMicro": false},
    {"text": "проверь ПДн в выгрузках CSV", "expectedType": "security", "expectedNode": "#71", "expectedMicro": false},
    {"text": "поставь postiz и подключи vk", "expectedType": "marketing", "expectedNode": "#81", "expectedMicro": false},
    {"text": "запусти Pest тесты", "expectedType": "bugfix", "expectedNode": "#18", "expectedMicro": false},
    {"text": "увеличь max-tokens в config", "expectedType": "unknown", "expectedNode": null, "expectedMicro": true},
    {"text": "сделай мониторинг для очередей через Sentry", "expectedType": "monitoring", "expectedNode": "#34", "expectedMicro": false},
    {"text": "разбери почему билы упали в pipeline", "expectedType": "bugfix", "expectedNode": null, "expectedMicro": false}
  ]
}
```

- [ ] **Step 2: Создать `tools/router-accuracy-runner.mjs`**

```javascript
#!/usr/bin/env node
/**
 * Accuracy runner — прогоняет 20 промптов через classifier (без LLM, regex only)
 * и выдаёт отчёт «правильно/неправильно» по каждому пункту.
 *
 * Использовать перед регистрацией router-prehook в settings.json.
 */

import { readFileSync } from 'fs';
import { classifyByRegex } from './router-classifier.mjs';
import { loadRegistry } from './registry-load.mjs';

function main() {
  const promptsFile = process.argv[2] || 'tools/router-test-prompts.json';
  const data = JSON.parse(readFileSync(promptsFile, 'utf-8'));
  const registry = loadRegistry({ useCache: false });

  let correctType = 0, correctNode = 0, correctMicro = 0, total = data.prompts.length;
  const failures = [];

  for (const p of data.prompts) {
    const r = classifyByRegex(p.text, registry);
    const typeOk = r.taskType === p.expectedType;
    const nodeOk = r.recommendedNode === p.expectedNode;
    const microOk = r.micro === p.expectedMicro;
    if (typeOk) correctType++;
    if (nodeOk) correctNode++;
    if (microOk) correctMicro++;
    if (!typeOk || !nodeOk || !microOk) {
      failures.push({
        prompt: p.text,
        expected: { type: p.expectedType, node: p.expectedNode, micro: p.expectedMicro },
        actual: { type: r.taskType, node: r.recommendedNode, micro: r.micro },
        deltas: { type: !typeOk, node: !nodeOk, micro: !microOk },
      });
    }
  }

  console.log('=== Accuracy Report ===');
  console.log(`Type accuracy: ${correctType}/${total} = ${(correctType / total * 100).toFixed(1)}%`);
  console.log(`Node accuracy: ${correctNode}/${total} = ${(correctNode / total * 100).toFixed(1)}%`);
  console.log(`Micro accuracy: ${correctMicro}/${total} = ${(correctMicro / total * 100).toFixed(1)}%`);
  console.log('');
  console.log(`Failures (${failures.length}):`);
  for (const f of failures) {
    console.log(`  «${f.prompt}»`);
    console.log(`    expected: type=${f.expected.type}, node=${f.expected.node}, micro=${f.expected.micro}`);
    console.log(`    actual:   type=${f.actual.type}, node=${f.actual.node}, micro=${f.actual.micro}`);
  }

  const passOverall = (correctType + correctNode + correctMicro) / (total * 3);
  process.exit(passOverall >= 0.75 ? 0 : 1);
}

main();
```

- [ ] **Step 3: Прогнать accuracy**

```bash
node tools/router-accuracy-runner.mjs
```

Expected: type accuracy ≥75%, node accuracy ≥75%, micro accuracy ≥75%. Если хуже — посмотреть failures, подкрутить regex'ы в `router-classifier.mjs` (Task 2), повторить.

**Если type accuracy <60%** — STOP, диагностируем. Regex слишком слабый.
**Если node accuracy <50%** — STOP, диагностируем. Доменная разметка реестра (Task 1) недостаточна, нужно расширить keyword'ы.

- [ ] **Step 4: Commit (с фактическими цифрами)**

```bash
git add tools/router-test-prompts.json tools/router-accuracy-runner.mjs
git commit -m "feat(router): 20-prompt accuracy runner — Phase A baseline (stage 3 task 5)

Ground truth: tools/router-test-prompts.json (20 промптов с expectedType/expectedNode/expectedMicro).
Runner: tools/router-accuracy-runner.mjs.

Baseline accuracy regex-only: type=X%, node=Y%, micro=Z%.
(Заполнить из фактического output.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## CHECKPOINT A — Заказчик ревьюит Phase A

**После Task 5** — пауза. Заказчик смотрит на:

1. Accuracy report (% по типу, узлу, micro).
2. Список failures (что классифицируется неправильно).
3. Принимает решение:
   - Accuracy ≥75% → продолжаем в Phase B (enforcement).
   - Accuracy 60-75% → подкручиваем regex / keyword'ы / promptовый prompt, повторяем Task 5.
   - Accuracy <60% → этап 3 заморожен, нужен дополнительный анализ.

**В этой точке остановки** — поведение Claude не изменилось. Никаких хуков в settings.json. Можно откатить весь Phase A одной командой `git revert HEAD~5..HEAD` без побочных эффектов.

---

## Phase B — Enforcement (Tasks 6-9)

После явного «продолжаем» от заказчика.

### Task 6: Router-tool-gate — PreToolUse hook (warn-only режим)

**Цель.** PreToolUse hook читает state из Task 4 и решает: блокировать или нет. **Первая итерация — warn-only**: пишет предупреждение в stderr, но не блокирует. Это даёт сутки наблюдения «как часто гейт сработал бы, если бы был включён» без реальных блокировок.

**Files:**

- Create: `tools/router-tool-gate.mjs`
- Test: `tools/router-tool-gate.test.mjs`

- [ ] **Step 1: Failing test**

`tools/router-tool-gate.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import {
  shouldBlock,
  decodeRoutingTag,
  isReadOnlyBash,
  decideDecision,
} from './router-tool-gate.mjs';

const enforcementState = {
  enforcementRequired: true,
  skillInvokedThisTurn: false,
  classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' },
  chainProgress: [],
};

describe('isReadOnlyBash', () => {
  it('detects ls / cat / grep / git status as read-only', () => {
    expect(isReadOnlyBash('ls -la')).toBe(true);
    expect(isReadOnlyBash('cat file.txt')).toBe(true);
    expect(isReadOnlyBash('grep "x" file')).toBe(true);
    expect(isReadOnlyBash('git status')).toBe(true);
    expect(isReadOnlyBash('git log')).toBe(true);
    expect(isReadOnlyBash('git rev-parse HEAD')).toBe(true);
  });

  it('does not classify git commit / push as read-only', () => {
    expect(isReadOnlyBash('git commit -m "x"')).toBe(false);
    expect(isReadOnlyBash('git push origin main')).toBe(false);
  });

  it('does not classify rm / cp / mv as read-only', () => {
    expect(isReadOnlyBash('rm file')).toBe(false);
    expect(isReadOnlyBash('cp a b')).toBe(false);
  });
});

describe('decodeRoutingTag', () => {
  it('parses direct_justified=true with reason', () => {
    const r = decodeRoutingTag('<!-- routing: direct_justified=true reason="micro fix per user override" -->');
    expect(r.directJustified).toBe(true);
    expect(r.reason).toContain('micro fix');
  });

  it('returns null on missing tag', () => {
    expect(decodeRoutingTag('just a regular response')).toBeNull();
  });

  it('rejects direct_justified=true WITHOUT reason', () => {
    const r = decodeRoutingTag('<!-- routing: direct_justified=true -->');
    expect(r).toBeNull();
  });
});

describe('shouldBlock', () => {
  it('blocks Edit on enforcement state without skill invoked', () => {
    expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true);
  });

  it('does NOT block when skill invoked this turn', () => {
    const state = { ...enforcementState, skillInvokedThisTurn: true };
    expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
  });

  it('does NOT block when enforcement not required', () => {
    const state = { ...enforcementState, enforcementRequired: false };
    expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
  });

  it('does NOT block when routing-tag has direct_justified=true with reason', () => {
    expect(shouldBlock('Edit', enforcementState, '<!-- routing: direct_justified=true reason="testing" -->', { warnOnly: false })).toBe(false);
  });

  it('does NOT block read-only Bash', () => {
    expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false);
  });

  it('warn-only mode never blocks (always returns false)', () => {
    expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false);
  });
});

describe('decideDecision', () => {
  it('returns decision: block with message when shouldBlock=true', () => {
    const r = decideDecision('Edit', enforcementState, '', { warnOnly: false });
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/#19/);
  });

  it('returns empty (proceed) when shouldBlock=false', () => {
    const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false });
    expect(r.decision).toBeUndefined();
  });

  it('warn-only mode logs to stderr but does not block', () => {
    const r = decideDecision('Edit', enforcementState, '', { warnOnly: true });
    expect(r.decision).toBeUndefined();
    expect(r.warning).toMatch(/#19/);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Реализовать `tools/router-tool-gate.mjs`**

```javascript
#!/usr/bin/env node
/**
 * PreToolUse hook — router tool gate.
 * Stage 3 of router discipline overhaul.
 *
 * Читает state из ~/.claude/runtime/router-state-<session>.json (написан router-prehook).
 * Решает: block / proceed для tools Edit, Write, Bash (non-read-only).
 *
 * Escape hatch: <!-- routing: direct_justified=true reason="..." --> в начале response пропускает.
 *
 * Mode: warn-only (только stderr) или enforce (decision: block).
 * Mode читается из ~/.claude/runtime/router-gate-mode.json {"mode": "warn-only"|"enforce"}.
 * По умолчанию warn-only (первая неделя), потом ручной переключатель.
 */

import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

const READ_ONLY_BASH_PATTERNS = [
  /^\s*ls(\s|$)/, /^\s*cat\s/, /^\s*head\s/, /^\s*tail\s/, /^\s*wc\s/,
  /^\s*grep\s/, /^\s*find\s.*-print/, /^\s*pwd\s*$/,
  /^\s*git\s+(status|log|show|diff|rev-parse|branch|ls-tree|ls-remote|remote\s+show|tag|fetch)/,
  /^\s*node\s.*--check/, /^\s*npx\s+vitest\s+run/, /^\s*node\s+tools\/[\w-]+\.mjs\s+/,
];

export function isReadOnlyBash(command) {
  if (!command) return false;
  return READ_ONLY_BASH_PATTERNS.some((re) => re.test(command));
}

export function decodeRoutingTag(responseText) {
  if (!responseText) return null;
  const m = String(responseText).match(/<!--\s*routing:\s*direct_justified=true\s+reason=["']([^"']+)["']\s*-->/);
  if (!m) return null;
  return { directJustified: true, reason: m[1] };
}

export function shouldBlock(tool, state, responseText, options = {}) {
  const warnOnly = options.warnOnly !== false; // default true
  if (warnOnly) return false;

  if (!state.enforcementRequired) return false;
  if (state.skillInvokedThisTurn) return false;

  if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
  if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;

  const tag = decodeRoutingTag(responseText);
  if (tag && tag.directJustified) return false;

  return true;
}

export function decideDecision(tool, state, responseText, options = {}) {
  const cls = state.classification || {};
  if (shouldBlock(tool, state, responseText, options)) {
    const recommendedNode = cls.recommendedNode || '(unknown)';
    const recommendedChain = cls.recommendedChain ? ` (chain ${cls.recommendedChain})` : '';
    return {
      decision: 'block',
      reason: `Эта задача классифицирована как ${cls.taskType}. Реестр рекомендует узел ${recommendedNode}${recommendedChain}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`,
    };
  }
  if (options.warnOnly && state.enforcementRequired && !state.skillInvokedThisTurn) {
    return {
      warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${cls.recommendedNode}.`,
    };
  }
  return {};
}

function gateMode() {
  const path = join(homedir(), '.claude', 'runtime', 'router-gate-mode.json');
  if (!existsSync(path)) return 'warn-only';
  try {
    const data = JSON.parse(readFileSync(path, 'utf-8'));
    return data.mode === 'enforce' ? 'enforce' : 'warn-only';
  } catch { return 'warn-only'; }
}

function readState(sessionId) {
  const path = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
  if (!existsSync(path)) return null;
  try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
}

async function main() {
  let input = '';
  for await (const chunk of process.stdin) input += chunk;
  const event = JSON.parse(input || '{}');
  const sessionId = event.session_id || 'unknown';
  const tool = event.tool_name;
  const state = readState(sessionId);

  if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }

  const mode = gateMode();
  const warnOnly = mode === 'warn-only';
  const responseText = ''; // PreToolUse event doesn't include response; routing-tag must be in tool params
  const bashCommand = (event.tool_input || {}).command || '';

  const decision = decideDecision(tool, state, responseText, { warnOnly, bashCommand });

  if (decision.warning) process.stderr.write(decision.warning + '\n');
  process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
  process.exit(0);
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
  main();
}
```

- [ ] **Step 4: Run — GREEN**

Все 14+ тестов PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/router-tool-gate.mjs tools/router-tool-gate.test.mjs
git commit -m "feat(router): PreToolUse gate — block Edit/Write/Bash без скила (stage 3 task 6)

Режим warn-only по умолчанию (только stderr); enforce переключается через
~/.claude/runtime/router-gate-mode.json {\"mode\":\"enforce\"}.
Escape hatch: <!-- routing: direct_justified=true reason=\"...\" -->.
Read-only Bash (ls/cat/grep/git status/...) пропускается всегда.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: Chain progress tracking — расширение Stop hook

**Цель.** После каждого хода Stop-гейт смотрит «какие скилы были вызваны?» и обновляет `chainProgress` в state-файле. Когда `chainProgress.length === chain.sequence.length` — цепочка завершена.

**Files:**

- Modify: `tools/observer-stop-hook.mjs` (или создать `tools/router-stop-gate.mjs` рядом)

- [ ] **Step 1: Прочитать существующий observer-stop-hook.mjs**

```bash
cat tools/observer-stop-hook.mjs | head -50
```

Понять что он делает (по плану наблюдателя — пишет эпизод в JSONL).

- [ ] **Step 2: Создать отдельный `tools/router-stop-gate.mjs` и тесты**

`tools/router-stop-gate.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { updateChainProgress, extractSkillInvocations } from './router-stop-gate.mjs';

const chains = {
  L1: { name: 'brainstorming chain', sequence: ['brainstorming', 'writing-plans', 'executing-plans'] },
  L13: { name: 'finance chain', sequence: ['billing-audit', 'pest', 'ru-tax-accounting'] },
};

describe('extractSkillInvocations', () => {
  it('extracts skill names from Skill tool invocations', () => {
    const events = [
      { tool_name: 'Skill', tool_input: { skill: 'brainstorming' } },
      { tool_name: 'Edit', tool_input: {} },
      { tool_name: 'Skill', tool_input: { skill: 'writing-plans' } },
    ];
    expect(extractSkillInvocations(events)).toEqual(['brainstorming', 'writing-plans']);
  });

  it('returns empty when no Skill invocations', () => {
    expect(extractSkillInvocations([{ tool_name: 'Edit' }])).toEqual([]);
  });

  it('strips namespace prefix (superpowers:brainstorming → brainstorming)', () => {
    const events = [{ tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } }];
    expect(extractSkillInvocations(events)).toEqual(['brainstorming']);
  });
});

describe('updateChainProgress', () => {
  it('appends matched chain step to chainProgress', () => {
    const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
    const updated = updateChainProgress(state, ['brainstorming'], chains);
    expect(updated.chainProgress).toEqual(['brainstorming']);
  });

  it('appends multiple steps if multiple skills invoked', () => {
    const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
    const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
    expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
  });

  it('ignores skills not in chain sequence', () => {
    const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
    const updated = updateChainProgress(state, ['random-skill'], chains);
    expect(updated.chainProgress).toEqual([]);
  });

  it('marks chainCompleted=true when last step reached', () => {
    const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming', 'writing-plans'] };
    const updated = updateChainProgress(state, ['executing-plans'], chains);
    expect(updated.chainCompleted).toBe(true);
  });

  it('preserves existing chainProgress without duplicates', () => {
    const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming'] };
    const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
    expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
  });
});
```

- [ ] **Step 3: Реализовать `tools/router-stop-gate.mjs`**

```javascript
#!/usr/bin/env node
/**
 * Stop hook addition — router chain progress tracking.
 * Stage 3 of router discipline overhaul.
 *
 * После каждого хода: обновляет state.chainProgress на основе вызванных в этом ходу скилов.
 * Helper для observer-stop-hook — он сам решает, вызывать ли (зависит от того, есть ли router-state).
 */

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

export function extractSkillInvocations(events) {
  return (events || [])
    .filter((e) => e && e.tool_name === 'Skill')
    .map((e) => {
      const raw = e.tool_input?.skill || '';
      const stripped = raw.includes(':') ? raw.split(':').pop() : raw;
      return stripped;
    })
    .filter(Boolean);
}

export function updateChainProgress(state, skillsInvoked, chains) {
  const chainId = state.classification?.recommendedChain;
  if (!chainId || !chains[chainId]) return { ...state };

  const sequence = chains[chainId].sequence || [];
  const currentProgress = [...(state.chainProgress || [])];

  for (const skill of skillsInvoked) {
    const nextExpectedIdx = currentProgress.length;
    if (nextExpectedIdx >= sequence.length) break;
    if (sequence[nextExpectedIdx] === skill) {
      currentProgress.push(skill);
    }
  }

  return {
    ...state,
    chainProgress: currentProgress,
    chainCompleted: currentProgress.length === sequence.length && sequence.length > 0,
  };
}

async function main() {
  let input = '';
  for await (const chunk of process.stdin) input += chunk;
  const event = JSON.parse(input || '{}');
  const sessionId = event.session_id || 'unknown';
  const events = event.turn_events || [];

  const statePath = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
  if (!existsSync(statePath)) { process.stdout.write('{}'); process.exit(0); return; }

  try {
    const state = JSON.parse(readFileSync(statePath, 'utf-8'));
    const { loadRegistry } = await import('./registry-load.mjs');
    const registry = loadRegistry({ useCache: false });
    const skills = extractSkillInvocations(events);
    const updated = updateChainProgress(state, skills, registry.chains || {});
    updated.skillInvokedThisTurn = skills.length > 0;
    writeFileSync(statePath, JSON.stringify(updated, null, 2));
    process.stdout.write('{}');
    process.exit(0);
  } catch (err) {
    process.stderr.write(`[router-stop-gate] ${err.message}\n`);
    process.stdout.write('{}');
    process.exit(0);
  }
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
  main();
}
```

- [ ] **Step 4: Run — GREEN**

- [ ] **Step 5: Commit**

```bash
git add tools/router-stop-gate.mjs tools/router-stop-gate.test.mjs
git commit -m "feat(router): Stop hook — chain progress tracking (stage 3 task 7)

После каждого хода обновляет state.chainProgress по реально вызванным
скилам. chainCompleted=true когда последний шаг достигнут.
skillInvokedThisTurn флажок для PreToolUse gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: Регистрация хуков в `.claude/settings.json` + warn-only включение

**Цель.** Подключить 3 хука к Claude Code, режим **warn-only**. Никакой реальной блокировки — только diagnostic warnings в stderr.

**Files:**

- Modify: `.claude/settings.json`

- [ ] **Step 1: Прочитать текущий `.claude/settings.json`**

```bash
cat .claude/settings.json | head -50
```

Понять структуру блока `hooks` (UserPromptSubmit / PreToolUse / Stop).

- [ ] **Step 2: Добавить 3 хука в соответствующие блоки**

В блок `"hooks.UserPromptSubmit"` массив — **добавить**:

```json
{
  "matcher": "*",
  "hooks": [
    {"type": "command", "command": "node tools/router-prehook.mjs"}
  ]
}
```

В блок `"hooks.PreToolUse"` массив — **добавить**:

```json
{
  "matcher": "Edit|Write|MultiEdit|Bash",
  "hooks": [
    {"type": "command", "command": "node tools/router-tool-gate.mjs"}
  ]
}
```

В блок `"hooks.Stop"` массив — **добавить**:

```json
{
  "matcher": "*",
  "hooks": [
    {"type": "command", "command": "node tools/router-stop-gate.mjs"}
  ]
}
```

- [ ] **Step 3: Зафиксировать mode = warn-only**

```bash
mkdir -p "$HOME/.claude/runtime"
echo '{"mode":"warn-only"}' > "$HOME/.claude/runtime/router-gate-mode.json"
```

- [ ] **Step 4: Smoke-test — открыть новую сессию Claude Code и попробовать**

(Это **ручной шаг** для контроллера, не для субагента. Subagent не может перезапустить Claude Code session.)

- Закрыть текущую сессию, открыть новую.
- Написать промпт «почини двойное списание лида у клиента id=7».
- Сделать любое Edit.
- Ожидаемо: warning в stderr `[router-gate WARN-ONLY] Edit would be blocked — recommended #62`.
- Ни одна блокировка не произошла.

- [ ] **Step 5: Commit**

```bash
git add .claude/settings.json
git commit -m "feat(router): register 3 hooks in .claude/settings.json (warn-only) (stage 3 task 8)

UserPromptSubmit → router-prehook (classifier).
PreToolUse Edit|Write|MultiEdit|Bash → router-tool-gate (warn-only).
Stop → router-stop-gate (chain progress).

router-gate-mode.json = warn-only. Переключение в enforce — отдельным
коммитом после Checkpoint B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## CHECKPOINT B — 24 часа warn-only наблюдения

**Между Task 8 и Task 9** — пауза **минимум 24 часа** реальной работы.

В этот период:

- Сторож не блокирует ничего.
- В stderr пишется когда сторож **сработал бы**.
- В журнале наблюдателя пишется фактическое решение классификатора по каждому промпту.

**Метрика готовности к enforce:** прогон `/brain-retro` за это окно покажет:

- Сколько раз сторож был активен (количество warnings).
- На каком количестве из этих — я вызвал нужный навык, на каком — нет.
- Сколько ложных срабатываний (warning на задачу где навык объективно не нужен).

**Решение заказчика:**

- Warnings адекватны (≥80% правильные) → переключаем в `enforce` (Task 9).
- Warnings шумные (>20% ложных) → возвращаемся к Task 1-3, поправляем разметку или regex.
- Совсем не работает — выключаем хуки (rollback за 5 минут).

---

### Task 9: Переключение в enforce + STATUS.md метрики

**Цель.** После согласия заказчика — переключаем mode → enforce. Заодно расширяем STATUS.md новыми метриками.

**Files:**

- Modify: `~/.claude/runtime/router-gate-mode.json`
- Modify: `tools/brain-retro-analyzer.mjs` (+ две оси)
- Modify: `tools/brain-retro-analyzer.test.mjs`
- Modify: `tools/status-md-generator.mjs` (+ две строки)
- Modify: `tools/status-md-generator.test.mjs`

- [ ] **Step 1: Failing tests — domain hit rate**

В `tools/brain-retro-analyzer.test.mjs` добавить:

```javascript
describe('analyze — domain hit rate (stage 3)', () => {
  it('returns domainHitRate slice', () => {
    const eps = [
      ep({ primary_rationale: { task_classification: 'feature', node_chosen: '#19', recommended_node: '#19', triggers_matched: [], boundaries_applied: [], step: 3, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
      ep({ primary_rationale: { task_classification: 'bugfix', node_chosen: 'direct', recommended_node: '#62', triggers_matched: [], boundaries_applied: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
    ];
    const res = analyze(eps, { classificationMap: {}, dormancy: {} });
    expect(res.domainHitRate.total).toBe(2);
    expect(res.domainHitRate.withRecommendation).toBe(2);
    expect(res.domainHitRate.matchedRecommendation).toBe(1); // First episode actually chose recommended
    expect(res.domainHitRate.rate).toBeCloseTo(0.5);
  });
});

describe('analyze — chain completion rate (stage 3)', () => {
  it('returns chainCompletionRate slice', () => {
    const eps = [
      ep({ primary_rationale: { recommended_chain: 'L1', chain_progress: ['brainstorming', 'writing-plans', 'executing-plans'], chain_completed: true, task_classification: 'feature', node_chosen: '#19', triggers_matched: [], boundaries_applied: [], step: 3, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
      ep({ primary_rationale: { recommended_chain: 'L1', chain_progress: ['brainstorming'], chain_completed: false, task_classification: 'feature', node_chosen: '#19', triggers_matched: [], boundaries_applied: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
    ];
    const res = analyze(eps, { classificationMap: {}, dormancy: {} });
    expect(res.chainCompletionRate.started).toBe(2);
    expect(res.chainCompletionRate.completed).toBe(1);
    expect(res.chainCompletionRate.rate).toBeCloseTo(0.5);
  });
});
```

- [ ] **Step 2: Реализовать новые оси в analyzer**

В `tools/brain-retro-analyzer.mjs` после `boundariesAppliedRate(normal)`:

```javascript
  function domainHitRate(episodes) {
    let total = 0, withRec = 0, matched = 0;
    for (const e of episodes) {
      if (!e.primary_rationale) continue;
      total += 1;
      const rec = e.primary_rationale.recommended_node;
      if (rec) {
        withRec += 1;
        if (e.primary_rationale.node_chosen === rec) matched += 1;
      }
    }
    return { total, withRecommendation: withRec, matchedRecommendation: matched, rate: withRec ? matched / withRec : 0 };
  }
  function chainCompletionRate(episodes) {
    let started = 0, completed = 0;
    for (const e of episodes) {
      const pr = e.primary_rationale || {};
      if (pr.recommended_chain) {
        started += 1;
        if (pr.chain_completed) completed += 1;
      }
    }
    return { started, completed, rate: started ? completed / started : 0 };
  }
  const domainHit = domainHitRate(normal);
  const chainCompletion = chainCompletionRate(normal);
```

И в `return`:

```javascript
    domainHitRate: domainHit,
    chainCompletionRate: chainCompletion,
```

- [ ] **Step 3: STATUS.md — две новых строки**

В `tools/status-md-generator.mjs` в `disciplineBlock` builder после строки `Boundaries applied...`:

```javascript
const domainPct = formatPercent(discipline.domainHitRate?.rate || 0);
const chainPct = formatPercent(discipline.chainCompletionRate?.rate || 0);
// ... в template:
Domain hit rate (правильный узел выбран): ${discipline.domainHitRate?.matchedRecommendation || 0} of ${discipline.domainHitRate?.withRecommendation || 0} (${domainPct}).

Chain completion rate (цепочки доведены до конца): ${discipline.chainCompletionRate?.completed || 0} of ${discipline.chainCompletionRate?.started || 0} (${chainPct}).
```

И в renderStatus CLI добавить `domainHitRate` + `chainCompletionRate` в `discipline` IIFE.

- [ ] **Step 4: Run все тесты**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs
```

Expected: всё GREEN.

- [ ] **Step 5: Переключить в enforce**

```bash
echo '{"mode":"enforce"}' > "$HOME/.claude/runtime/router-gate-mode.json"
```

- [ ] **Step 6: Smoke-test enforce**

(Ручной шаг для контроллера.)

- Открыть новую сессию Claude Code.
- Промпт «давай сделаем фичу — кнопка экспорта в Deals».
- Попробовать Edit без вызова `writing-plans`.
- **Ожидаемо:** Edit заблокирован, сообщение «реестр рекомендует #19 Superpowers (chain L1)».
- Написать в начале ответа `<!-- routing: direct_justified=true reason="testing escape hatch" -->`, попробовать Edit.
- **Ожидаемо:** Edit прошёл.

- [ ] **Step 7: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs tools/status-md-generator.mjs tools/status-md-generator.test.mjs docs/observer/STATUS.md
git commit -m "feat(router): enforce mode ON + domain hit / chain completion metrics (stage 3 task 9)

router-gate-mode.json: enforce.
brain-retro-analyzer: +domainHitRate + chainCompletionRate.
STATUS.md: +2 строки в блоке «Метрики дисциплины».

Live-verified: блокировка работает, escape hatch работает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Phase C — Continuity + финал

### Task 10: Continuity + memory update + final regression + push

**Files:**

- Modify: `docs/observer/active-projects.md`
- Modify: outside-repo `memory/project_router_overhaul.md` (controller)
- Modify: outside-repo `memory/MEMORY.md` (controller)

- [ ] **Step 1: Update `docs/observer/active-projects.md`**

```markdown
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
  - Этап 1 ✅ закрыт 2026-05-23.
  - Этап 2 ✅ закрыт 2026-05-24.
  - Этап 3 ✅ закрыт 2026-XX-XX (дата завершения Task 9) — classifier + 3 хука + Task 0a/0b + chain governance. mode=enforce. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
  - Этап 4 (уборка нормативки) ⏸ ждёт «продолжаем».
```

- [ ] **Step 2: Регенерировать STATUS.md**

```bash
node tools/status-md-generator.mjs
```

- [ ] **Step 3: Финальная регрессия**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs
```

Expected: все GREEN, ≥ +30 новых тестов от стартового baseline.

- [ ] **Step 4: gitleaks**

```bash
./bin/gitleaks.exe detect --no-banner --redact
```

Expected: 0 leaks.

- [ ] **Step 5: Memory update (controller — outside repo)**

Контроллер обновляет `memory/project_router_overhaul.md` (этапы 1+2+3 закрыты, lessons learned этапа 3) и `MEMORY.md` (поправить строку).

- [ ] **Step 6: Commit + push**

```bash
git add docs/observer/active-projects.md docs/observer/STATUS.md
git commit -m "docs(continuity): stage 3 closed — active-projects + STATUS regen

Final regression: <X>f / <Y>t GREEN.
mode=enforce active.

Memory pointer обновлён controller'ом (outside-repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

git push origin <branch>:feat/router-overhaul-stage-3-enforcement
```

---

## Acceptance Criteria (Definition of Done всего этапа 3)

После Task 10:

- ✅ Classifier работает в two-layer mode (regex + LLM с реестром в prompt'е).
- ✅ Accuracy на 20-promt test set ≥75% по типу/узлу/micro.
- ✅ 3 хука зарегистрированы в `.claude/settings.json`.
- ✅ Mode: enforce.
- ✅ Escape hatch (routing-tag) работает live.
- ✅ STATUS.md показывает domain hit rate + chain completion rate.
- ✅ После 1 недели работы: дисциплина ≥60% от baseline (на feature/planning).
- ✅ Cost ≤$15-20/мес (Anthropic billing).
- ✅ Откатываемость: удаление 3 строк из settings.json + revert коммитов ≤30 минут.

---

## Self-Review

**1. Spec coverage** (включая amendment Task 0a/0b/chain governance):

- ✅ Task 0a (доменная разметка) — Task 1 + Task 2 (classifier ищет по keyword).
- ✅ Task 0b (цепочки) — buildLLMPrompt включает chains, classifier возвращает recommendedChain, Task 7 chain progress, Task 9 chain completion rate.
- ✅ Chain governance — упомянуто в spec amendment, в плане НЕ создаётся (это не код, а правила правок реестра — продолжают действовать).
- ✅ Layer 1 regex + Layer 2 Sonnet — Task 2 + Task 3.
- ✅ UserPromptSubmit + PreToolUse + Stop хуки — Task 4 + Task 6 + Task 7.
- ✅ Escape hatch — Task 6 (decodeRoutingTag).
- ✅ Cache + budget guard — Task 3 (cache в classify()).
- ✅ Warn-only → enforce переход — Task 8 → Task 9.
- ✅ Test prompts — Task 5.
- ✅ STATUS.md метрики — Task 9.
- ✅ Откатываемость ≤5 минут — Task 8/9 (settings.json + mode file).

**2. Placeholders:**

- ✅ Нет «TBD», «implement later», «handle edge cases» без кода.
- ✅ Чёткое distinction: ручные шаги (smoke-test после settings.json) явно помечены «не для субагента».
- ⚠️ Task 9 Step 1 expectedNode сравнения зависят от точного фактического значения `chain_progress` в эпизодах после Task 7 деплоя — тесты валидны на artificial fixtures.

**3. Type consistency:**

- `classifyByRegex` → `{taskType, micro, recommendedNode, confidence, source}` — везде то же.
- `classify` (async) — тот же shape + `recommendedChain` + опционально `llmError`.
- State в router-state-<session>.json — `{sessionId, promptHash, classification, skillInvokedThisTurn, chainProgress, enforcementRequired, timestamp}`. Consistent в Task 4, 6, 7.
- `decideDecision` → `{decision?: 'block', reason?: string, warning?: string}` — consistent.
- Mode file format — `{mode: 'warn-only' | 'enforce'}` — consistent в Task 6 и Task 9.

**4. Риски и митигации:**

- LLM down → fallback на regex result (Task 3 classify()).
- Любая ошибка прехука → silent fallback, проход (Task 4 main()).
- ENV var ANTHROPIC_API_KEY missing → Pre-flight Step 5 ловит ДО старта работы.
- False positive enforcement → warn-only режим 24+ часа перед enforce (Checkpoint B).
- Параллельные сессии → каждая session_id = свой router-state файл.
- Cost overrun → cache + Haiku-default (не Sonnet) + ручной мониторинг через Anthropic dashboard.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — диспетчирую свежего субагента на каждую таску (Sonnet/Opus per Pravila §15.1), review между тасками, быстрая итерация.

**2. Inline Execution** — выполняю в этой сессии через `superpowers:executing-plans`, batch с checkpoint'ами на ревью.

**Какой подход?**
