# Router discipline overhaul — Stage 2 (Measurements) 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:** Получить honest baseline дисциплины роутера (% эпизодов с матченным триггером по типам задач, на каком шаге роутер реально остановился, % применённых границ ADR), переключить `missed-activations.mjs` с устаревшего `observer-classification-map.json` + `extract-node-dormancy.mjs` на единый реестр `docs/registry/nodes.yaml`, и закоммитить baseline-снимок для сравнения с пост-enforcement цифрами в этапе 3.

**Architecture:** Pure-функции на JS-ES-modules без exec/LLM. Новый адаптер `registry-to-classification-map.mjs` строит `{classification → [nodeIds]}` и `{id → dormant_bool}` напрямую из реестра (поля `status` + `triggers.classification`). Новый модуль `discipline-metrics.mjs` считает три среза. `brain-retro-analyzer.mjs` и `status-md-generator.mjs` модифицируются для потребления. Baseline-снимок — markdown с замороженными числами.

**Tech Stack:** Node.js ESM, Vitest, существующие `js-yaml` + `ajv` для реестра, существующая JSONL-инфраструктура наблюдателя.

---

## Pre-flight (обязательно до Task 1)

Pravila §15.2: pre-flight sync для нормативки. На этом этапе мы НЕ правим 8 нормативных файлов (Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql) — только `docs/registry/nodes.yaml`, `tools/*.mjs`, `tools/*.json` и новый baseline в `docs/observer/baselines/`. Но реестр через lefthook job 17 рендерит auto-region в Tooling §4.0 — если есть несинхронизированные коммиты, рендер может вытащить чужие изменения.

- [ ] **Step 1: Sync с origin/main**

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

Expected: либо пусто (HEAD up-to-date), либо короткий список — тогда `git pull --ff-only origin main` ДО создания worktree.

- [ ] **Step 2: Создать изолированный worktree через superpowers:using-git-worktrees**

Worktree-имя: `worktree-router-stage2-measurements`, ветка: `feat/router-overhaul-stage-2-measurements` от свежего `origin/main`. Скопировать gitignored-зависимости как обычно (`.env`, `app/.env`, `bin/`, `node_modules/` через `npm ci` свежим, `app/vendor/` через `composer install`).

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

```bash
npx vitest run tools/
```

Expected: GREEN на всех текущих `tools/*.test.mjs` (≥40 файлов тестов). Если красное — фиксим перед стартом, иначе нельзя различить «новый красный» vs «уже было красное».

---

## File Structure

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

- `tools/registry-to-classification-map.mjs` — pure адаптер реестр → `{classificationMap, dormancy}`.
- `tools/registry-to-classification-map.test.mjs` — unit-тесты адаптера.
- `tools/discipline-metrics.mjs` — pure модуль с тремя срезами (`disciplinePercentByClassification`, `routerStepReached`, `boundariesAppliedRate`).
- `tools/discipline-metrics.test.mjs` — unit-тесты срезов.
- `docs/observer/baselines/2026-05-24-pre-enforcement.md` — baseline-снимок.

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

- `docs/registry/nodes.yaml` — пополнить classification-триггеры под текущий `observer-classification-map.json` (10 категорий, ~14 узлов).
- `tools/brain-retro-analyzer.mjs` — `analyze()` принимает `registry` опционально, новые поля в результате; CLI читает реестр вместо `observer-classification-map.json`.
- `tools/brain-retro-analyzer.test.mjs` — новые describe-блоки на 3 среза.
- `tools/status-md-generator.mjs` — `renderStatus()` принимает `discipline` опционально; CLI читает реестр + считает срезы.
- `tools/status-md-generator.test.mjs` — тест блока «Метрики дисциплины».
- `tools/observer-classification-map.json` — обновить `description` (deprecated note, source-of-truth → реестр, оставлен для historic v2-эпизодов).

**Не трогаем:**

- Pravila/CLAUDE.md/PSR_v1/Tooling/ADR — этап 2 нормативку не меняет (это этап 4).
- `tools/extract-node-dormancy.mjs` — остаётся работать (lefthook job всё ещё генерирует `.node-dormancy.json`), но `brain-retro-analyzer` CLI больше его не читает. Удаление — этап 4.
- Хуки `.claude/settings.json` — этап 3.
- Поведение Claude — этап 3.

---

## Task 1: Пополнить classification-триггеры в реестре

**Зачем.** Сейчас в `nodes.yaml` `classification:` триггеры только у нескольких узлов (#18 Pest, #19 Superpowers + ~2-3 других). `observer-classification-map.json` ссылается на 27 уникальных id (#11/#12/#18/#19/#25/#34/#35/#39/#41/#42/#43/#53/#64/#65/#68..#81). Без пополнения адаптер из Task 2 вернёт почти пустой `classificationMap`, и `missed-activations` сломается (sanity-check провалится).

**Files:**

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

**Источник правды для маппинга** — текущий `tools/observer-classification-map.json` (после правок 23.05 — `question: []`, `memory-sync: []` оставляем пустыми; никаких триггеров `classification: question` или `classification: memory-sync` НЕ добавляем).

Маппинг (то, что добавляем):

| classification | nodes (добавить `classification:` trigger каждому) |
|---|---|
| refactor | #11 Pint, #12 Larastan, #43 deptrac, #64 Rector, #65 PHP Insights |
| bugfix | #18 Pest, #34 Sentry MCP |
| feature | #19 Superpowers |
| planning | #19 Superpowers, #41 CCPM, #42 product-management |
| monitoring | #34 Sentry MCP, #35 Redis MCP |
| analysis | #25 Semgrep, #39 Trail of Bits Skills, #53 process-analysis |
| security | #73 security-go-live, #69 Nuclei, #68 OWASP ZAP, #70 Ward, #71 pdn-152fz-audit, #72 threat-model |
| marketing | #74 marketing, #77 marketing-ru, #75 marketingskills, #76 brand-voice, #78 Метрика, #79 Wordstat, #80 Telegram, #81 Postiz |
| cleanup | #11 Pint, #12 Larastan |
| memory-sync | (нет — оставляем пустым) |
| question | (нет — оставляем пустым) |

- [ ] **Step 1: Прочитать текущие триггеры у узла #11 (Pint)**

Найти блок `- id: "#11"` в `docs/registry/nodes.yaml`, посмотреть формат `triggers:`.

- [ ] **Step 2: Прогнать sanity-check на текущем состоянии baseline**

```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | tee /tmp/brain-retro-before.json
node -e 'const r=require("/tmp/brain-retro-before.json");console.log("missed:",r.missedActivations.totalMissed)'
```

Expected: число missed activations (по STATUS.md сейчас ~21, фактическая цифра зафиксируется в snapshot'е).

Зафиксировать число — оно понадобится в Task 6 sanity-check.

- [ ] **Step 3: Добавить classification-триггеры на все 14 узлов из таблицы маппинга**

Для каждого узла в таблице — добавить (или дополнить) блок `triggers:` строкой `- {classification: "<имя>", weight: 1.0}`. Существующие keyword-триггеры не удаляем. Узел может получить несколько classification-триггеров (например #11 Pint → `refactor` + `cleanup`; #19 Superpowers → `feature` + `planning`; #34 Sentry → `bugfix` + `monitoring`).

Пример для #11 Pint (если сейчас только keyword-триггеры):

```yaml
  - id: "#11"
    name: "Laravel Pint"
    slug: "pint"
    # ... остальные поля ...
    triggers:
      - {keyword: "code style", weight: 1.0}   # ← существующий, не трогаем
      - {classification: "refactor", weight: 1.0}   # ← НОВЫЙ
      - {classification: "cleanup", weight: 1.0}   # ← НОВЫЙ
```

Все 14 узлов править в ОДНОМ коммите.

- [ ] **Step 4: Валидировать YAML против схемы**

```bash
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` без ошибки `Registry schema violation`. Если ошибка — посмотреть detail и поправить YAML.

- [ ] **Step 5: Прогнать существующие тесты реестра**

```bash
npx vitest run tools/registry-load.test.mjs tools/registry-render.test.mjs
```

Expected: GREEN. Если красное — Task 1 не закрыт, диагностируем.

- [ ] **Step 6: Проверить, что auto-render в Tooling не дрейфит**

```bash
node tools/registry-render.mjs --check
git diff docs/Tooling_v8_3.md docs/routing-off-phase.md
```

Expected: `--check` либо exit 0 без diff, либо exit 1 с явным сообщением. Если render сгенерировал изменения — это ожидаемо (мы расширили триггеры). Закоммитим вместе с реестром.

- [ ] **Step 7: Закоммитить**

```bash
git add docs/registry/nodes.yaml docs/Tooling_v8_3.md docs/routing-off-phase.md
git commit -m "feat(registry): expand classification triggers on 14 nodes (stage 2 task 1)

Source of truth for classification → recommended nodes мигрируется из
tools/observer-classification-map.json в реестр.

См. docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md

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

---

## Task 2: Адаптер «реестр → classificationMap + dormancy»

**Зачем.** Новый чистый адаптер, чтобы `missed-activations.mjs` и `brain-retro-analyzer.mjs` потребляли реестр напрямую, без посредника `observer-classification-map.json` и без `extract-node-dormancy.mjs` (последний устарел — dormancy теперь живёт в `nodes.yaml` поле `status`).

**Files:**

- Create: `tools/registry-to-classification-map.mjs`
- Test: `tools/registry-to-classification-map.test.mjs`

- [ ] **Step 1: Написать failing test**

`tools/registry-to-classification-map.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';

function node(id, status, classificationTriggers) {
  return {
    id,
    status,
    triggers: classificationTriggers.map(c => ({ classification: c, weight: 1.0 })),
  };
}

const registry = {
  nodes: [
    node('#11', 'active',   ['refactor', 'cleanup']),
    node('#12', 'active',   ['refactor', 'cleanup']),
    node('#17', 'dormant',  ['refactor']),
    node('#44', 'deferred', ['feature']),
    node('#1',  'historic', ['analysis']),
    node('#19', 'active',   ['feature', 'planning']),
    node('#99', 'active',   []), // no classification triggers — ignored
  ],
};

describe('buildClassificationMap', () => {
  it('groups active nodes by classification', () => {
    const map = buildClassificationMap(registry);
    expect(map.refactor.sort()).toEqual(['#11', '#12']);
    expect(map.feature).toEqual(['#19']);
    expect(map.planning).toEqual(['#19']);
    expect(map.cleanup.sort()).toEqual(['#11', '#12']);
  });

  it('excludes dormant nodes', () => {
    const map = buildClassificationMap(registry);
    expect(map.refactor).not.toContain('#17');
  });

  it('excludes deferred nodes', () => {
    const map = buildClassificationMap(registry);
    expect(map.feature || []).not.toContain('#44');
  });

  it('excludes historic nodes', () => {
    const map = buildClassificationMap(registry);
    expect(map.analysis).toBeUndefined();
  });

  it('ignores nodes with no classification triggers', () => {
    const map = buildClassificationMap(registry);
    expect(Object.values(map).flat()).not.toContain('#99');
  });

  it('returns an empty object on an empty registry', () => {
    expect(buildClassificationMap({ nodes: [] })).toEqual({});
  });
});

describe('buildDormancyMap', () => {
  it('marks dormant nodes true', () => {
    const dorm = buildDormancyMap(registry);
    expect(dorm['#17']).toBe(true);
  });

  it('marks deferred nodes true', () => {
    const dorm = buildDormancyMap(registry);
    expect(dorm['#44']).toBe(true);
  });

  it('marks historic nodes true', () => {
    const dorm = buildDormancyMap(registry);
    expect(dorm['#1']).toBe(true);
  });

  it('marks active nodes false', () => {
    const dorm = buildDormancyMap(registry);
    expect(dorm['#11']).toBe(false);
    expect(dorm['#19']).toBe(false);
  });
});
```

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

```bash
npx vitest run tools/registry-to-classification-map.test.mjs
```

Expected: FAIL с `Cannot find module './registry-to-classification-map.mjs'`.

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

`tools/registry-to-classification-map.mjs`:

```javascript
#!/usr/bin/env node
/**
 * Pure adapter: docs/registry/nodes.yaml → {classificationMap, dormancy}.
 *
 * Replaces tools/observer-classification-map.json (single point of edit
 * для маппинга «task_classification → recommended node ids») and
 * tools/extract-node-dormancy.mjs (Tooling §4.X scraping for status).
 *
 * Pure / read-only. No exec, no fs (caller passes loaded registry).
 * Source of truth for missed-activation detection (Pravila §16.4 v1.36).
 */

/**
 * Group active-status nodes by their `classification:` trigger value.
 * Returns `{ [classification]: [nodeId, ...] }`. Nodes without classification
 * triggers, or non-active (dormant/deferred/historic), are excluded.
 */
export function buildClassificationMap(registry) {
  const out = {};
  for (const node of registry.nodes || []) {
    if (node.status !== 'active') continue;
    for (const t of node.triggers || []) {
      if (!t.classification) continue;
      const c = t.classification;
      if (!out[c]) out[c] = [];
      if (!out[c].includes(node.id)) out[c].push(node.id);
    }
  }
  return out;
}

/**
 * Build dormancy map for missed-activations consumer: id → true iff node is
 * effectively unavailable (status ∈ {dormant, deferred, historic}).
 * Active nodes: false. Unknown nodes: absent.
 */
export function buildDormancyMap(registry) {
  const out = {};
  for (const node of registry.nodes || []) {
    out[node.id] = node.status !== 'active';
  }
  return out;
}
```

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

```bash
npx vitest run tools/registry-to-classification-map.test.mjs
```

Expected: PASS, 10/10 GREEN.

- [ ] **Step 5: Закоммитить**

```bash
git add tools/registry-to-classification-map.mjs tools/registry-to-classification-map.test.mjs
git commit -m "feat(brain): pure adapter registry → {classificationMap, dormancy}

Stage 2 Task 2 — заменяет observer-classification-map.json и
extract-node-dormancy.mjs как источник истины для missed-activation
matcher. Реестр nodes.yaml становится single source.

Pure module, read-only, без exec/fs (caller passes loaded registry).

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

---

## Task 3: Discipline-metrics — три новых среза (pure)

**Files:**

- Create: `tools/discipline-metrics.mjs`
- Test: `tools/discipline-metrics.test.mjs`

**Срезы** (spec §этап 2):

1. **`disciplinePercentByClassification(episodes, classificationMap)`** → `{ [classification]: { episodes: N, withTriggerMatch: M, viaSkill: K, pctTriggerMatch: 0.0-1.0, pctViaSkill: 0.0-1.0 } }`.

   - **Triggered**: `primary_rationale.triggers_matched.length > 0`.
   - **Via skill** (i.e. *НЕ direct*): `primary_rationale.node_chosen !== 'direct'`.
   - Только эпизоды с `schema_version >= 2`, не `observer_error`, с известной `task_classification` (есть в `classificationMap`).
   - Деление на 0 → 0.0.

2. **`routerStepReached(episodes)`** → `{ '1': N, '2': N, '3': N, '4': N, '5': N, 'null': N }`.

   - `primary_rationale.step` (число 1-5 или null) — где роутер остановился по `docs/router-procedure.md` (5 шагов).
   - **Diagnostic flag** в результате: если `step === 1` для >90% эпизодов — это вероятный баг парсера (sentinel), флаг `suspicious: true`.

3. **`boundariesAppliedRate(episodes)`** → `{ total: N, withBoundaries: M, rate: 0.0-1.0, byPathType: { regulated: {...}, free: {...} } }`.

   - **withBoundaries**: `Array.isArray(primary_rationale.boundaries_applied) && length > 0`.
   - Дополнительная разбивка по `path_type` (regulated/free/null).

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

`tools/discipline-metrics.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import {
  disciplinePercentByClassification,
  routerStepReached,
  boundariesAppliedRate,
} from './discipline-metrics.mjs';

const map = { feature: ['#19'], bugfix: ['#18'], refactor: ['#11', '#12'] };

function ep(overrides = {}) {
  return {
    schema_version: 2,
    primary_rationale: {
      task_classification: 'feature',
      node_chosen: 'direct',
      triggers_matched: [],
      boundaries_applied: [],
      step: 1,
    },
    path_type: 'regulated',
    ...overrides,
  };
}

describe('disciplinePercentByClassification', () => {
  it('counts episodes per classification', () => {
    const eps = [
      ep({ primary_rationale: { task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
      ep({ primary_rationale: { task_classification: 'feature', node_chosen: '#19', triggers_matched: [{node:'#19'}], boundaries_applied: [], step: 3 } }),
      ep({ primary_rationale: { task_classification: 'bugfix', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
    ];
    const res = disciplinePercentByClassification(eps, map);
    expect(res.feature.episodes).toBe(2);
    expect(res.feature.withTriggerMatch).toBe(1);
    expect(res.feature.viaSkill).toBe(1);
    expect(res.feature.pctTriggerMatch).toBeCloseTo(0.5);
    expect(res.feature.pctViaSkill).toBeCloseTo(0.5);
    expect(res.bugfix.episodes).toBe(1);
    expect(res.bugfix.pctTriggerMatch).toBe(0);
  });

  it('ignores classifications outside the map', () => {
    const eps = [ep({ primary_rationale: { task_classification: 'unknown', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } })];
    const res = disciplinePercentByClassification(eps, map);
    expect(res.unknown).toBeUndefined();
  });

  it('ignores v1 episodes and observer_error markers', () => {
    const eps = [
      { schema_version: 1, primary_rationale: { task_classification: 'feature', node_chosen: 'direct' } },
      { observer_error: true },
      ep(),
    ];
    const res = disciplinePercentByClassification(eps, map);
    expect(res.feature.episodes).toBe(1);
  });

  it('returns empty object on empty input', () => {
    expect(disciplinePercentByClassification([], map)).toEqual({});
  });
});

describe('routerStepReached', () => {
  it('counts episodes by step', () => {
    const eps = [
      ep({ primary_rationale: { step: 1 } }),
      ep({ primary_rationale: { step: 1 } }),
      ep({ primary_rationale: { step: 3 } }),
      ep({ primary_rationale: { step: null } }),
    ];
    const res = routerStepReached(eps);
    expect(res.distribution['1']).toBe(2);
    expect(res.distribution['3']).toBe(1);
    expect(res.distribution['null']).toBe(1);
  });

  it('flags suspicious=true when >90% эпизодов остановились на step=1', () => {
    const eps = Array.from({ length: 11 }, (_, i) =>
      ep({ primary_rationale: { step: i === 10 ? 3 : 1 } })
    );
    const res = routerStepReached(eps);
    expect(res.suspicious).toBe(true);
  });

  it('suspicious=false when distribution более равномерное', () => {
    const eps = [
      ep({ primary_rationale: { step: 1 } }),
      ep({ primary_rationale: { step: 2 } }),
      ep({ primary_rationale: { step: 3 } }),
    ];
    const res = routerStepReached(eps);
    expect(res.suspicious).toBe(false);
  });

  it('ignores v1 episodes and observer_error markers', () => {
    const eps = [
      { schema_version: 1, primary_rationale: { step: 5 } },
      { observer_error: true },
      ep({ primary_rationale: { step: 2 } }),
    ];
    const res = routerStepReached(eps);
    expect(res.distribution).toEqual({ '2': 1 });
  });
});

describe('boundariesAppliedRate', () => {
  it('counts overall rate of boundaries applied', () => {
    const eps = [
      ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-001' }] } }),
      ep({ primary_rationale: { boundaries_applied: [] } }),
      ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-002' }] } }),
    ];
    const res = boundariesAppliedRate(eps);
    expect(res.total).toBe(3);
    expect(res.withBoundaries).toBe(2);
    expect(res.rate).toBeCloseTo(2 / 3);
  });

  it('splits by path_type', () => {
    const eps = [
      ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [{ adr: 'X' }] } }),
      ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [] } }),
      ep({ path_type: 'free',      primary_rationale: { boundaries_applied: [{ adr: 'Y' }] } }),
    ];
    const res = boundariesAppliedRate(eps);
    expect(res.byPathType.regulated.total).toBe(2);
    expect(res.byPathType.regulated.withBoundaries).toBe(1);
    expect(res.byPathType.free.total).toBe(1);
    expect(res.byPathType.free.withBoundaries).toBe(1);
  });

  it('returns rate=0 on empty input', () => {
    expect(boundariesAppliedRate([])).toEqual({
      total: 0, withBoundaries: 0, rate: 0, byPathType: {},
    });
  });
});
```

- [ ] **Step 2: Запустить — красное**

```bash
npx vitest run tools/discipline-metrics.test.mjs
```

Expected: FAIL `Cannot find module`.

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

`tools/discipline-metrics.mjs`:

```javascript
#!/usr/bin/env node
/**
 * Discipline metrics — pure aggregation over observer episodes.
 * Stage 2 of router discipline overhaul (spec 2026-05-23): baseline measurement
 * перед enforcement в этапе 3.
 *
 * Read-only, no exec, no fs.
 */

/** Filter helper: only schema v2+ non-error episodes. */
function valid(episodes) {
  return (episodes || []).filter(
    (e) => e && !e.observer_error && typeof e.schema_version === 'number' && e.schema_version >= 2
  );
}

/**
 * % эпизодов с матченным триггером, разбивка по task_classification.
 * Только классификации, присутствующие в classificationMap (т.е. известные/имеющие узлы).
 */
export function disciplinePercentByClassification(episodes, classificationMap) {
  const out = {};
  for (const e of valid(episodes)) {
    const pr = e.primary_rationale || {};
    const cls = pr.task_classification;
    if (!cls || !classificationMap[cls]) continue;
    if (!out[cls]) out[cls] = { episodes: 0, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 };
    const b = out[cls];
    b.episodes += 1;
    if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) b.withTriggerMatch += 1;
    if (pr.node_chosen && pr.node_chosen !== 'direct') b.viaSkill += 1;
  }
  for (const b of Object.values(out)) {
    b.pctTriggerMatch = b.episodes ? b.withTriggerMatch / b.episodes : 0;
    b.pctViaSkill = b.episodes ? b.viaSkill / b.episodes : 0;
  }
  return out;
}

/**
 * Распределение по шагу роутера, на котором эпизод остановился.
 * Sentinel-флаг suspicious=true, если >90% эпизодов на step=1 (вероятный баг парсера).
 */
export function routerStepReached(episodes) {
  const distribution = {};
  let total = 0;
  for (const e of valid(episodes)) {
    const step = (e.primary_rationale || {}).step;
    const key = step === null || step === undefined ? 'null' : String(step);
    distribution[key] = (distribution[key] || 0) + 1;
    total += 1;
  }
  const stuckAt1 = (distribution['1'] || 0) / Math.max(total, 1);
  return { distribution, total, suspicious: total >= 5 && stuckAt1 > 0.9 };
}

/**
 * Доля эпизодов с непустыми applied boundaries, разбивка по path_type.
 */
export function boundariesAppliedRate(episodes) {
  let total = 0, withBoundaries = 0;
  const byPathType = {};
  for (const e of valid(episodes)) {
    const pr = e.primary_rationale || {};
    const pt = e.path_type || 'null';
    const has = Array.isArray(pr.boundaries_applied) && pr.boundaries_applied.length > 0;
    total += 1;
    if (has) withBoundaries += 1;
    if (!byPathType[pt]) byPathType[pt] = { total: 0, withBoundaries: 0, rate: 0 };
    byPathType[pt].total += 1;
    if (has) byPathType[pt].withBoundaries += 1;
  }
  for (const b of Object.values(byPathType)) b.rate = b.total ? b.withBoundaries / b.total : 0;
  return { total, withBoundaries, rate: total ? withBoundaries / total : 0, byPathType };
}
```

- [ ] **Step 4: Зелёное**

```bash
npx vitest run tools/discipline-metrics.test.mjs
```

Expected: PASS, 11/11 GREEN.

- [ ] **Step 5: Закоммитить**

```bash
git add tools/discipline-metrics.mjs tools/discipline-metrics.test.mjs
git commit -m "feat(brain): discipline-metrics — 3 среза для baseline (stage 2 task 3)

Pure-функции: disciplinePercentByClassification / routerStepReached /
boundariesAppliedRate. Read-only, без exec/fs. Sentinel-флаг suspicious
для router step=1 stuck-bug (Pravila §16.4 sanity-check).

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

---

## Task 4: Интегрировать срезы и адаптер в brain-retro-analyzer

**Files:**

- Modify: `tools/brain-retro-analyzer.mjs` (analyze() сигнатура расширяется, CLI читает реестр)
- Modify: `tools/brain-retro-analyzer.test.mjs` (новые describe-блоки)

- [ ] **Step 1: Failing test — analyze() возвращает три новых среза**

Добавить в `tools/brain-retro-analyzer.test.mjs` (в конец файла):

```javascript
describe('analyze — discipline metrics (stage 2)', () => {
  const map = { feature: ['#19'], bugfix: ['#18'] };
  const dormancy = { '#19': false, '#18': false };

  it('returns disciplinePercentByClassification', () => {
    const eps = [
      ep({ primary_rationale: { task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
      ep({ primary_rationale: { task_classification: 'feature', node_chosen: '#19', triggers_matched: [{node:'#19'}], boundaries_applied: [], step: 3, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
    ];
    const res = analyze(eps, { classificationMap: map, dormancy });
    expect(res.disciplineByClassification.feature.episodes).toBe(2);
    expect(res.disciplineByClassification.feature.withTriggerMatch).toBe(1);
    expect(res.disciplineByClassification.feature.viaSkill).toBe(1);
  });

  it('returns routerStepReached distribution', () => {
    const eps = [
      ep({ primary_rationale: { step: 1, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
      ep({ primary_rationale: { step: 3, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
    ];
    const res = analyze(eps, { classificationMap: map, dormancy });
    expect(res.routerStep.distribution['1']).toBe(1);
    expect(res.routerStep.distribution['3']).toBe(1);
  });

  it('returns boundariesAppliedRate', () => {
    const eps = [
      ep({ primary_rationale: { boundaries_applied: [{ adr: 'X' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
      ep({ primary_rationale: { boundaries_applied: [],            task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1, candidates_considered: [], hard_floor: { invoked: false, rules: [] } } }),
    ];
    const res = analyze(eps, { classificationMap: map, dormancy });
    expect(res.boundariesRate.total).toBe(2);
    expect(res.boundariesRate.withBoundaries).toBe(1);
    expect(res.boundariesRate.rate).toBeCloseTo(0.5);
  });
});
```

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

```bash
npx vitest run tools/brain-retro-analyzer.test.mjs
```

Expected: 3 FAIL: `res.disciplineByClassification is undefined` и т.д.

- [ ] **Step 3: Импортнуть в analyzer и расширить analyze()**

В `tools/brain-retro-analyzer.mjs`:

Добавить импорт после строки `import { detectMissedActivations } from './missed-activations.mjs';`:

```javascript
import {
  disciplinePercentByClassification,
  routerStepReached,
  boundariesAppliedRate,
} from './discipline-metrics.mjs';
```

В функции `analyze(episodes, options = {})` ПЕРЕД `return {`:

```javascript
  const disciplineByClassification = disciplinePercentByClassification(normal, classificationMap);
  const routerStep = routerStepReached(normal);
  const boundariesRate = boundariesAppliedRate(normal);
```

В `return { ... }` добавить три поля:

```javascript
    disciplineByClassification,
    routerStep,
    boundariesRate,
```

- [ ] **Step 4: Тесты зелёные**

```bash
npx vitest run tools/brain-retro-analyzer.test.mjs
```

Expected: ВСЕ тесты PASS (включая 3 новых + ~20 существующих).

- [ ] **Step 5: Переключить CLI на реестр**

В нижнем `if (process.argv[1] && ...)` блоке `tools/brain-retro-analyzer.mjs` — заменить чтение `observer-classification-map.json` и `.node-dormancy.json` на чтение реестра.

Старое:

```javascript
  const classificationMap = (() => {
    try {
      return JSON.parse(readFileSync('tools/observer-classification-map.json', 'utf-8')).map || {};
    } catch { return {}; }
  })();
  const dormancy = (() => {
    try {
      return JSON.parse(readFileSync('tools/.node-dormancy.json', 'utf-8'));
    } catch { return {}; }
  })();
```

Новое:

```javascript
  const registry = await import('./registry-load.mjs').then(m => m.loadRegistry({ useCache: false }));
  const adapter = await import('./registry-to-classification-map.mjs');
  const classificationMap = adapter.buildClassificationMap(registry);
  const dormancy = adapter.buildDormancyMap(registry);
```

(NB: top-level `await` поддержан в Node 14.8+ только в `.mjs` — у нас файлы `.mjs`, OK. Если возникнет проблема — обернуть в `async function main()` и вызвать в конце.)

- [ ] **Step 6: Smoke-test CLI на текущих данных**

```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | tee /tmp/brain-retro-after.json
node -e 'const r=require("/tmp/brain-retro-after.json");console.log("missed:",r.missedActivations.totalMissed,"discipline keys:",Object.keys(r.disciplineByClassification));console.log("step dist:",r.routerStep.distribution);console.log("boundaries rate:",r.boundariesRate.rate.toFixed(3))'
```

Expected:

- `missed:` число ±5 от зафиксированного в Task 1 Step 2 baseline (sanity-check: переключение источника правды не должно радикально изменить missed activations).
- `discipline keys:` непустой массив с типами задач из карты.
- `step dist:` — пишется реальное распределение.
- `boundaries rate:` — число между 0 и 1.

**Если missed activation отличается больше чем на 5** — стоп, диагностируем (вероятно, в Task 1 не все nodes размечены). Поправить реестр, повторить.

- [ ] **Step 7: Закоммитить**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain): analyze() returns 3 discipline slices + CLI reads registry

Stage 2 Task 4 — analyze() расширен:
  disciplineByClassification, routerStep, boundariesRate.

CLI (tools/brain-retro-analyzer.mjs source-of-truth) теперь читает
classificationMap и dormancy из docs/registry/nodes.yaml через
registry-to-classification-map.mjs (вместо observer-classification-map.json
и .node-dormancy.json).

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

---

## Task 5: STATUS.md — блок «Метрики дисциплины»

**Files:**

- Modify: `tools/status-md-generator.mjs`
- Modify: `tools/status-md-generator.test.mjs`

- [ ] **Step 1: Failing test — renderStatus принимает discipline и отображает таблицу**

Открыть `tools/status-md-generator.test.mjs`, добавить describe:

```javascript
describe('renderStatus — discipline block (stage 2)', () => {
  const baseInputs = {
    now: '2026-05-24T10:00:00Z',
    c1: { status: 'ok', detail: 'OK' },
    c2: { status: 'ok', detail: 'OK' },
    c3: { status: 'ok', detail: 'OK' },
    c5: { status: 'ok', detail: 'OK' },
    c6: { status: 'ok', detail: 'OK' },
    observer: { episodeCount: 10, observerErrors: 0, piiMatches: 0, v1Episodes: 0 },
    missed: { totalMissed: 0, byNode: {}, byClassification: {} },
    lastRetroDaysAgo: 0,
  };

  it('renders discipline table when discipline data is provided', () => {
    const md = renderStatus({
      ...baseInputs,
      discipline: {
        byClassification: {
          feature:  { episodes: 5, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 },
          bugfix:   { episodes: 6, withTriggerMatch: 2, viaSkill: 2, pctTriggerMatch: 0.333, pctViaSkill: 0.333 },
        },
        routerStep: { distribution: { '1': 10, '3': 1 }, total: 11, suspicious: true },
        boundariesRate: { total: 11, withBoundaries: 3, rate: 0.273, byPathType: {} },
      },
    });
    expect(md).toMatch(/## Метрики дисциплины/);
    expect(md).toMatch(/feature/);
    expect(md).toMatch(/bugfix/);
    expect(md).toMatch(/33\.3%/);
    expect(md).toMatch(/router step distribution/i);
    expect(md).toMatch(/⚠️.*suspicious/i);
    expect(md).toMatch(/boundaries applied/i);
    expect(md).toMatch(/27\.3%/);
  });

  it('omits the discipline block when discipline is absent (backward compat)', () => {
    const md = renderStatus(baseInputs);
    expect(md).not.toMatch(/## Метрики дисциплины/);
  });
});
```

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

```bash
npx vitest run tools/status-md-generator.test.mjs
```

Expected: 2 FAIL.

- [ ] **Step 3: Реализовать блок в renderStatus()**

В `tools/status-md-generator.mjs` функции `renderStatus(inputs)`:

Деструктуризацию расширить:

```javascript
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo, discipline } = inputs;
```

Перед `const projectsBlock = ...` добавить:

```javascript
function formatPercent(p) { return `${(p * 100).toFixed(1)}%`; }

let disciplineBlock = '';
if (discipline) {
  const rows = Object.entries(discipline.byClassification || {})
    .sort((a, b) => b[1].episodes - a[1].episodes)
    .map(([cls, b]) => `| ${cls} | ${b.episodes} | ${formatPercent(b.pctTriggerMatch)} | ${formatPercent(b.pctViaSkill)} |`)
    .join('\n');
  const stepDist = Object.entries(discipline.routerStep?.distribution || {})
    .map(([k, v]) => `${k}: ${v}`).join(', ');
  const suspicious = discipline.routerStep?.suspicious
    ? ' ⚠️ suspicious — >90% эпизодов остановились на step=1 (вероятный sentinel-bug парсера)'
    : '';
  const boundariesPct = formatPercent(discipline.boundariesRate?.rate || 0);
  disciplineBlock = `
## Метрики дисциплины

Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3.

| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
${rows || '| (no data) | 0 | 0% | 0% |'}

Router step distribution: ${stepDist || '(empty)'}${suspicious}

Boundaries applied (ADR / границы): ${discipline.boundariesRate?.withBoundaries || 0} of ${discipline.boundariesRate?.total || 0} эпизодов (${boundariesPct}).
`;
}
```

В возвращаемой template-строке вставить `${disciplineBlock}` между блоком `## Метрики (информационные...)` и `${projectsBlock}`:

```javascript
  // ... existing template ...
- Использование узлов: см. \`/brain-retro\` ...
${disciplineBlock}${projectsBlock}
## Алерт-индикаторы
```

- [ ] **Step 4: Тесты зелёные**

```bash
npx vitest run tools/status-md-generator.test.mjs
```

Expected: GREEN, существующие тесты НЕ сломаны (backward-compat — `discipline` опционален).

- [ ] **Step 5: Подключить discipline-расчёты в CLI**

В нижнем CLI-блоке `tools/status-md-generator.mjs` (`if (process.argv[1] && ...)`):

Добавить импорты в начале файла:

```javascript
import { analyze } from './brain-retro-analyzer.mjs';
import { loadRegistry } from './registry-load.mjs';
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
```

И функцию подгрузки эпизодов:

```javascript
function loadCurrentMonthEpisodes() {
  const month = new Date().toISOString().slice(0, 7);
  const file = join('docs', 'observer', `episodes-${month}.jsonl`);
  if (!existsSync(file)) return [];
  const out = [];
  for (const line of readFileSync(file, 'utf-8').split('\n')) {
    const t = line.trim();
    if (!t) continue;
    try { out.push(JSON.parse(t)); } catch { /* skip */ }
  }
  return out;
}
```

В CLI-блоке, в `inputs = { ... }`, добавить:

```javascript
    discipline: (() => {
      try {
        const registry = loadRegistry({ useCache: false });
        const classificationMap = buildClassificationMap(registry);
        const dormancy = buildDormancyMap(registry);
        const eps = loadCurrentMonthEpisodes();
        const a = analyze(eps, { classificationMap, dormancy });
        return {
          byClassification: a.disciplineByClassification,
          routerStep: a.routerStep,
          boundariesRate: a.boundariesRate,
        };
      } catch (err) {
        console.warn('[status-md-generator] discipline calc skipped:', err.message);
        return null;
      }
    })(),
```

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

```bash
node tools/status-md-generator.mjs
cat docs/observer/STATUS.md | head -80
```

Expected:

- Файл записан.
- Видна секция `## Метрики дисциплины` с реальной таблицей.
- Видна строка `Router step distribution: ...`.
- Видна строка `Boundaries applied: ...`.

Если получился suspicious=true ⚠️ — это диагностический сигнал, **не блокер** для этапа 2 (часть baseline'а — увидеть факт).

- [ ] **Step 7: Закоммитить**

```bash
git add tools/status-md-generator.mjs tools/status-md-generator.test.mjs docs/observer/STATUS.md
git commit -m "feat(brain): STATUS.md — блок «Метрики дисциплины» (stage 2 task 5)

Auto-generated блок с разбивкой % дисциплины по типам задач,
router-step distribution + suspicious-флаг, boundaries-applied rate.
Backward-compat: блок опускается, если discipline не передан.

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

---

## Task 6: Sanity-check + baseline-снимок

**Файлы:**

- Create: `docs/observer/baselines/2026-05-24-pre-enforcement.md`
- Modify: `tools/observer-classification-map.json` (только description — deprecated note)

- [ ] **Step 1: Сравнить missed activations до/после**

Из Task 1 Step 2 у нас зафиксировано `missed_before`. Сейчас:

```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | tee /tmp/brain-retro-final.json
node -e 'const r=require("/tmp/brain-retro-final.json");console.log(JSON.stringify({missed:r.missedActivations.totalMissed,byClassification:r.missedActivations.byClassification,disciplineKeys:Object.keys(r.disciplineByClassification),routerStep:r.routerStep,boundariesRate:r.boundariesRate.rate.toFixed(3)},null,2))'
```

Expected: `missed` отличается от `missed_before` не более чем на 5 (sanity-check). Если delta больше — диагностируем причину (вероятно, не все узлы из observer-classification-map.json получили classification-триггер в реестре в Task 1).

- [ ] **Step 2: Сформировать baseline-снимок**

`docs/observer/baselines/2026-05-24-pre-enforcement.md`:

```markdown
# Baseline дисциплины роутера — pre-enforcement snapshot

**Дата:** 2026-05-24
**Источник данных:** `docs/observer/episodes-2026-05.jsonl` (173 эпизода)
**Этап:** Router discipline overhaul, Stage 2 (Measurements). Зафиксирован для сравнения с пост-enforcement цифрами этапа 3.
**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`

## Цифры

(Заполнить ИЗ ВЫВОДА Step 1, не выдумывать. Все числа — фактические из brain-retro-analyzer на коммите <hash>.)

### Дисциплина по типам задач

| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| (вставить из output) | | | |

### Распределение по шагам роутера

(вставить distribution + suspicious flag)

### Применение границ (ADR)

- Total: N
- With boundaries: M
- Rate: K%
- By path_type: regulated K%, free K%

### Missed activations

- Total: N
- By classification: { ... }
- By node (top 5): { ... }

## Контекст

Это «точка До» перед включением enforcement-хука этапа 3. После недели работы хука повторно снимем эти цифры и сравним.

**Цели overhaul'а (из spec'а §acceptance criteria):**

- Дисциплина (% эпизодов с матченным триггером на классифицированных задачах): **≥75%** (baseline зафиксирован выше).
- Missed activations: **≤5/неделю** (baseline зафиксирован выше).
- % feature/planning без skill: **≤10%** (см. таблицу дисциплины).

## Воспроизводимость

```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl
```

Источник classificationMap + dormancy — `docs/registry/nodes.yaml` (через `tools/registry-to-classification-map.mjs`).

```

Заполнить таблицы фактическими цифрами из Step 1 output.

- [ ] **Step 3: Пометить observer-classification-map.json как deprecated**

В `tools/observer-classification-map.json` обновить поле `description` — добавить в начало строки:

```

"description": "DEPRECATED (2026-05-24): source of truth migrated to docs/registry/nodes.yaml + tools/registry-to-classification-map.mjs. This file is retained ONLY for historic v2-episode replay in tests; new code MUST consume the registry. Removal scheduled for stage 4 of router-discipline-overhaul. Original description follows. — [предыдущий текст description без изменений]"

```

Map не трогаем (`question: []`, `memory-sync: []` остаются на месте — фактические правила в реестре теперь идентичны: эти классификации просто не упомянуты в classification-триггерах ни одного узла).

- [ ] **Step 4: Закоммитить**

```bash
git add docs/observer/baselines/2026-05-24-pre-enforcement.md tools/observer-classification-map.json
git commit -m "docs(brain): baseline pre-enforcement snapshot (stage 2 task 6)

Зафиксированы цифры дисциплины роутера на 2026-05-24 перед запуском
enforcement-хука этапа 3. Sanity-check passed: missed activations
delta ≤5 после переключения источника правды на реестр.

observer-classification-map.json помечен deprecated — для удаления в этапе 4.

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

---

## Task 7: Обновить активные проекты в STATUS.md + memory

**Files:**

- Modify: `docs/observer/active-projects.md`
- Create: `memory/project_router_overhaul.md` (если не существует)

Continuity-механизм из спеки §Continuity: чтобы я не забыл этап 3.

- [ ] **Step 1: Update active-projects.md**

Прочитать текущий `docs/observer/active-projects.md` и в блоке «Router discipline overhaul» обновить статус этапа 2:

```markdown
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
  - Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
  - Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
  - Этап 3 (принуждение — хук на routing) ⏸ ждёт «продолжаем» от заказчика после ревью baseline-цифр.
  - Этап 4 (уборка правил) — не начат.
```

- [ ] **Step 2: Сгенерировать актуальный STATUS.md**

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

Expected: «Активные многоэтапные проекты» в STATUS.md отражает обновлённый текст.

- [ ] **Step 3: Создать memory-файл (если отсутствует)**

`memory/project_router_overhaul.md`:

```markdown
---
name: project-router-overhaul
description: Router discipline overhaul — текущая стадия, ссылка на spec и план, что блокирует переход к следующему этапу
metadata:
  type: project
---

**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`

**Прогресс:**

- Этап 1 ✅ 2026-05-23 (`docs/registry/nodes.yaml` + render)
- Этап 2 ✅ 2026-05-24 (discipline-metrics + brain-retro + STATUS блок + baseline `docs/observer/baselines/2026-05-24-pre-enforcement.md`). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
- Этап 3 ⏸ ждёт «продолжаем» от заказчика после ревью baseline-цифр.
- Этап 4 не начат.

**Что блокирует этап 3:** ревью заказчиком baseline-цифр + явное «делаем этап 3».

**Что не забыть к этапу 3:**
- Бюджет Sonnet escalation classifier ≤$15/мес (cap в `tools/router-classifier.mjs`).
- Routing-tag escape hatch (`<!-- routing: direct_justified=true ... -->`).
- Откатываемость: всё через `.claude/settings.json` хуки, никакой машинальной правки кода — rollback за 5 минут.

См. [[feedback_feature_via_writing_plans]], [[project_state]].
```

И добавить строку в `MEMORY.md`:

```markdown
- [Router discipline overhaul](project_router_overhaul.md) — этапы 1+2 закрыты; этап 3 (enforcement) ждёт «продолжаем» после ревью baseline
```

- [ ] **Step 4: Закоммитить**

```bash
git add docs/observer/active-projects.md docs/observer/STATUS.md memory/project_router_overhaul.md memory/MEMORY.md
git commit -m "docs(continuity): stage 2 closed — active-projects + memory pointer

Continuity-механизм из spec §Continuity: тройная страховка
(STATUS.md + memory + brain-retro) чтобы запустить этап 3 после ревью.

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

---

## Task 8: Финальная регрессия + PR

- [ ] **Step 1: Прогнать ВСЕ тесты tools/**

```bash
npx vitest run tools/
```

Expected: GREEN, ≥ baseline pre-flight + новые. 0 регрессий.

- [ ] **Step 2: gitleaks на коммитах этапа**

```bash
./bin/gitleaks.exe detect --source=. --redact
```

Expected: 0 leaks.

- [ ] **Step 3: lefthook job 17 (registry-render-check)**

```bash
npx lefthook run pre-commit
```

Expected: GREEN (warn-only сейчас, но не должен ругаться — render идемпотентен).

- [ ] **Step 4: Push в feature-branch**

```bash
git push -u origin feat/router-overhaul-stage-2-measurements
```

- [ ] **Step 5: Открыть PR (или передать заказчику для ручного review)**

```bash
gh pr create --title "Router overhaul Stage 2 — measurements + baseline" --body "$(cat <<'EOF'
## Summary

Этап 2 router discipline overhaul (spec 2026-05-23). Получили honest baseline дисциплины перед enforcement.

- ✅ Pure адаптер `registry-to-classification-map.mjs` — реестр становится single source для classification → recommended nodes.
- ✅ Pure модуль `discipline-metrics.mjs` — три среза: % триггер-матча по типам задач, distribution router-step, % применённых границ.
- ✅ `brain-retro-analyzer.mjs` — analyze() возвращает три новых поля; CLI читает реестр.
- ✅ `status-md-generator.mjs` — блок «Метрики дисциплины» в STATUS.md.
- ✅ Baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`.
- ✅ `observer-classification-map.json` помечен deprecated (removal — этап 4).
- ✅ Continuity: active-projects + memory pointer + MEMORY.md обновлены.

## Sanity check

Missed activations до переключения источника правды: <N> (baseline зафиксирован в Task 1 Step 2).
После переключения: <N±≤5>. Delta в норме.

## Что НЕ изменилось

- Поведение Claude — этап 3.
- Нормативка (Pravila/CLAUDE.md/PSR_v1/Tooling/ADR) — этап 4.
- `.claude/settings.json` хуки — этап 3.

## Откат

Все изменения локализованы в `tools/*.mjs`, `docs/registry/nodes.yaml` (только новые classification-триггеры), `docs/observer/baselines/` (новый файл), `docs/observer/STATUS.md` (auto-generated). Revert ветки = полный откат за 1 коммит.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

---

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

- ✅ STATUS.md показывает разбивку дисциплины по типам задач + router-step distribution + boundaries-applied rate.
- ✅ Baseline snapshot закоммичен (`docs/observer/baselines/2026-05-24-pre-enforcement.md`) с реальными цифрами.
- ✅ Все существующие тесты `tools/` + новые срезы — GREEN.
- ✅ `brain-retro-analyzer.mjs` CLI читает реестр (`docs/registry/nodes.yaml`) вместо `observer-classification-map.json` и `.node-dormancy.json`.
- ✅ Sanity-check: `missedActivations.totalMissed` после переключения отличается от baseline не более чем на 5.
- ✅ `observer-classification-map.json` помечен deprecated в `description`.
- ✅ Active-projects.md + memory pointer + MEMORY.md обновлены.
- ✅ 0 регрессий в `npx vitest run tools/`.
- ✅ 0 leaks gitleaks.

**Точка остановки:** ты смотришь на цифры baseline-snapshot'а («дисциплина X% на feature, Y% на planning, suspicious-flag Z»), говоришь «продолжаем» → план этапа 3, либо «пауза» → ждём.

---

## Self-Review

**1. Spec coverage** (spec §этап 2):

- ✅ Три новых среза в `brain-retro-analyzer.mjs` — Task 3+4.
- ✅ `missed-activations.mjs` переключён на реестр — Task 2 (адаптер) + Task 4 (CLI integration).
- ✅ STATUS.md generator — блок «Метрики дисциплины» — Task 5.
- ✅ Baseline snapshot — Task 6.
- ✅ Sanity-check missed activations не дрейфит — Task 4 Step 6 + Task 6 Step 1.
- ✅ Существующие + новые тесты GREEN — Task 8 Step 1.

Что НЕ требовалось спекой, но добавлено для consistency:

- Task 1 — пополнение classification-триггеров в реестре (без этого Task 2 адаптер вернёт пустую карту, и missed activations упадёт до 0).
- Task 7 — continuity-механизм (вне scope §этап 2, но spec §Continuity требует тройную страховку).

**2. Placeholders:** все шаги содержат конкретный код / команды / expected output. Поля в Task 6 baseline-снимке помечены «вставить из output» — это намеренно (фактическая цифра неизвестна до Step 1 запуска).

**3. Type consistency:**

- `buildClassificationMap(registry)` → `{ [classification]: string[] }` — везде то же.
- `buildDormancyMap(registry)` → `{ [id]: boolean }` — везде то же, совместимо с `detectMissedActivations(classificationMap, dormancy)`.
- `analyze()` возвращает `disciplineByClassification` / `routerStep` / `boundariesRate` — те же имена в Task 4 / 5 / 6.
- `renderStatus(inputs)` поле `discipline` с подполями `byClassification` / `routerStep` / `boundariesRate` — consistent в Task 5 Step 1 и Step 5.

**4. Риски и митигации (явные в плане):**

- Task 4 Step 6 sanity-check ловит дрейф missed activations >5 → останавливаем, диагностируем.
- Task 7 continuity-механизм закрывает риск R-7 из spec'а (забыть запустить этап 3).
- Task 8 регрессия + gitleaks + lefthook — три независимых гейта.

---

## Execution Handoff

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

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

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

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