# Router/Hooks Fixes — Phase 1 (Quick Wins) 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:** Закрыть 2 быстрые победы из 10 кандидатов brain-retro #9 + self-retrospect 28.05: убрать stale-шум #37 mermaid в deploy/monitoring (Cuts 8/9/10) и добавить блок «System Health» в STATUS.md.

**Architecture:** Изменения локальны и независимы. Task A правит `tools/brain-retro-analyzer.mjs` (или архивный JSON) — оба обратимы. Task B добавляет новый pure-функциональный блок в `tools/status-md-generator.mjs` и регистрирует его в render-цепочке.

**Tech Stack:** Node.js (.mjs), vitest, PowerShell (для system-health опроса процессов).

**Контекст:** этот план — **первая** из 5 фаз закрытия 10 кандидатов. Полный roadmap внизу.

---

## Контекст из brain-retro #9 / self-retrospect 28.05

- **Task A (Mermaid noise).** В retro #9 Cut 8 «Class × canon coverage» Mermaid #37 фигурировал как рекомендованный для deploy/monitoring 4 раза. Проверка `docs/registry/nodes.yaml:644-659` — триггеры #37 чисты (`c4`, `architecture-диаграммы`, `mermaid диаграмма`). Шум приходит из **архивного fallback** в `tools/brain-retro-analyzer.mjs:643-648` — анализатор внутри Cuts 8/9/10 читает `docs/archive/llm-bootstrap-2026-05/routing-docs/observer-classification-map.json` если map не передан. Где-то путь вызова обходит передачу map → archive побеждает.
- **Task B (System Health).** Sanity Q2 retro #9 — наблюдатель не видит висящие фоновые процессы. PID 6444 (adr-judge.py) крутился 7+ часов / CPU=25435s — никак не сигналил в episodes. Кандидат: блок в `STATUS.md` с топ-3 long-running процессов (CPU > 1 час).

---

## File Structure

| Файл | Responsibility | Действие |
|---|---|---|
| `tools/brain-retro-analyzer.mjs` | Анализатор retro, computes Cuts 8/9/10 | **Modify** — убрать archive-fallback ИЛИ всегда передавать `classificationMap` |
| `tools/brain-retro-analyzer.test.mjs` | Тесты анализатора | **Modify** — добавить regression-test «нет archive-fallback при пустом map» |
| `tools/status-md-generator.mjs` | Сборка STATUS.md | **Modify** — добавить `computeSystemHealthBlock()` + регистрация |
| `tools/status-md-generator.test.mjs` | Тесты генератора | **Modify** — добавить тест на новый блок |
| `tools/system-health.mjs` | Опрос процессов через PowerShell | **Create** — pure module, возвращает массив `{pid, name, cpuSeconds, ageMinutes}` |
| `tools/system-health.test.mjs` | Тесты опроса | **Create** — мок execFileSync, проверка парсинга PowerShell-вывода |

---

## Task A: Убрать archive-fallback из analyzer Cuts 8/9/10

**Files:**

- Modify: `tools/brain-retro-analyzer.mjs:643-664` (block около `canonMapForCuts`)
- Modify: `tools/brain-retro-analyzer.test.mjs` (добавить regression-test)

### Step A.1: Прочитать текущий код fallback'а

Run: `node -e "const fs=require('fs'); const c=fs.readFileSync('tools/brain-retro-analyzer.mjs','utf-8').split('\n').slice(640,670).join('\n'); console.log(c)"`

Expected: видеть блок типа

```js
// Cuts 8/9/10 — read classificationMap from the archived file when not
// provided. TODO: derive from nodes.yaml triggers instead.
let canonMapForCuts = classificationMap;
if (!Object.keys(canonMapForCuts || {}).length) {
  try {
    const mapPath = pathResolve('docs/archive/llm-bootstrap-2026-05/routing-docs/observer-classification-map.json');
    canonMapForCuts = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
  } catch {}
}
```

- [ ] **Step A.2: Написать failing test «нет archive-чтения при пустом map»**

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

```js
import { describe, it, expect, vi } from 'vitest';
import { analyze } from './brain-retro-analyzer.mjs';
import fs from 'fs';

describe('analyze() archive-fallback regression', () => {
  it('does NOT read archived classification map when classificationMap is empty', () => {
    const readFileSpy = vi.spyOn(fs, 'readFileSync');
    const episodes = [
      { schema_version: 2, primary_rationale: { task_classification: 'bugfix', node_chosen: 'direct' }, events: [] },
    ];
    analyze(episodes, { classificationMap: {} });
    const calls = readFileSpy.mock.calls
      .map(c => String(c[0]))
      .filter(p => p.includes('observer-classification-map.json'));
    expect(calls).toEqual([]);
    readFileSpy.mockRestore();
  });
});
```

- [ ] **Step A.3: Запустить тест — должен упасть**

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "archive-fallback regression" 2>&1 | tail -20`
Expected: FAIL — текущий код читает архив.

- [ ] **Step A.4: Удалить archive-fallback из analyzer**

В `tools/brain-retro-analyzer.mjs` найти блок строк 643-664 и заменить:

```js
// Cuts 8/9/10 — use classificationMap derived from nodes.yaml (registry-to-classification-map.mjs).
// Archive-fallback REMOVED 2026-05-28 — was stale source of #37/deploy noise.
const canonMapForCuts = classificationMap || {};
```

Удалить любые `import fs from 'fs'` если они появились только ради этого fallback'а; оставить если нужны где-то ещё.

- [ ] **Step A.5: Запустить тест — теперь PASS**

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "archive-fallback regression"`
Expected: PASS.

- [ ] **Step A.6: Прогнать полный тест-набор analyzer**

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs 2>&1 | tail -10`
Expected: ALL PASS, 0 failures.

- [ ] **Step A.7: E2E проверка — прогнать analyzer на майских эпизодах**

Run: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl 2>&1 | node -e "let b='';process.stdin.on('data',d=>b+=d);process.stdin.on('end',()=>{const r=JSON.parse(b); console.log('CCC:',JSON.stringify(r.classCanonCoverage,null,2))})"`
Expected: `classCanonCoverage` теперь строится из nodes.yaml-derived map (через `buildClassificationMap`). #37 для deploy/monitoring **не должен** появляться — только если триггеры #37 в nodes.yaml пересекаются (они не пересекаются, проверено выше).

- [ ] **Step A.8: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "$(cat <<'EOF'
fix(brain-retro): remove archive-fallback from analyzer Cuts 8/9/10

Stale `docs/archive/llm-bootstrap-2026-05/routing-docs/observer-classification-map.json`
was being read inside Cuts 8/9/10 when classificationMap was empty.
Source of #37 mermaid noise in retro #9 deploy/monitoring missed-activations.

Analyzer now uses nodes.yaml-derived map exclusively (single SoT per ADR-016).
Regression test added.

Closes brain-retro #9 candidate 3.
EOF
)"
```

---

## Task B: Блок «System Health» в STATUS.md

**Files:**

- Create: `tools/system-health.mjs`
- Create: `tools/system-health.test.mjs`
- Modify: `tools/status-md-generator.mjs` (импорт + регистрация блока)
- Modify: `tools/status-md-generator.test.mjs` (тест на новый блок в выводе)

### Step B.1: Написать failing test для system-health.mjs

Create `tools/system-health.test.mjs`:

```js
import { describe, it, expect, vi } from 'vitest';
import { parsePowerShellOutput, computeSystemHealthBlock } from './system-health.mjs';

describe('parsePowerShellOutput', () => {
  it('parses CSV output from PowerShell Get-Process', () => {
    const csv = `Id,ProcessName,CPU,StartTime
6444,python,25435.12,2026-05-27T18:30:00
1234,node,120.5,2026-05-28T07:00:00`;
    const parsed = parsePowerShellOutput(csv, new Date('2026-05-28T08:00:00'));
    expect(parsed).toHaveLength(2);
    expect(parsed[0]).toMatchObject({ pid: 6444, name: 'python', cpuSeconds: 25435.12 });
    expect(parsed[0].ageMinutes).toBeGreaterThan(800);
  });

  it('returns empty array on empty input', () => {
    expect(parsePowerShellOutput('', new Date())).toEqual([]);
  });
});

describe('computeSystemHealthBlock', () => {
  it('returns block with top long-running processes (CPU > 3600s)', () => {
    const procs = [
      { pid: 6444, name: 'python', cpuSeconds: 25435, ageMinutes: 815 },
      { pid: 1234, name: 'node', cpuSeconds: 120, ageMinutes: 60 },
    ];
    const block = computeSystemHealthBlock(procs);
    expect(block).toContain('System Health');
    expect(block).toContain('6444');
    expect(block).toContain('python');
    expect(block).not.toContain('1234');
  });

  it('returns "Долго работающих процессов нет" when no procs > 3600s', () => {
    const block = computeSystemHealthBlock([{ pid: 1, name: 'x', cpuSeconds: 100, ageMinutes: 10 }]);
    expect(block).toContain('Долго работающих процессов нет');
  });
});
```

- [ ] **Step B.2: Запустить — должен упасть**

Run: `npx vitest run tools/system-health.test.mjs 2>&1 | tail -10`
Expected: FAIL — `system-health.mjs` не существует.

- [ ] **Step B.3: Создать `tools/system-health.mjs`**

```js
import { execFileSync } from 'child_process';

const CPU_THRESHOLD_SECONDS = 3600; // 1 hour

export function parsePowerShellOutput(csv, now = new Date()) {
  const lines = csv.trim().split(/\r?\n/);
  if (lines.length < 2) return [];
  const header = lines[0].split(',');
  const idxPid = header.indexOf('Id');
  const idxName = header.indexOf('ProcessName');
  const idxCpu = header.indexOf('CPU');
  const idxStart = header.indexOf('StartTime');
  const procs = [];
  for (let i = 1; i < lines.length; i++) {
    const cells = lines[i].split(',');
    if (cells.length < 4) continue;
    const startTime = new Date(cells[idxStart]);
    const ageMinutes = Math.max(0, Math.floor((now - startTime) / 60000));
    procs.push({
      pid: parseInt(cells[idxPid], 10),
      name: cells[idxName],
      cpuSeconds: parseFloat(cells[idxCpu]),
      ageMinutes,
    });
  }
  return procs;
}

export function queryRunningProcesses() {
  try {
    const out = execFileSync('powershell.exe', [
      '-NoProfile', '-NonInteractive',
      '-Command',
      'Get-Process | Where-Object {$_.CPU -gt 3600} | Select-Object Id,ProcessName,CPU,StartTime | ConvertTo-Csv -NoTypeInformation | ForEach-Object {$_ -replace "\"",""}'
    ], { encoding: 'utf-8', timeout: 5000 });
    return parsePowerShellOutput(out, new Date());
  } catch (e) {
    return [];
  }
}

export function computeSystemHealthBlock(procs) {
  const heavy = (procs || [])
    .filter(p => p.cpuSeconds > CPU_THRESHOLD_SECONDS)
    .sort((a, b) => b.cpuSeconds - a.cpuSeconds)
    .slice(0, 3);
  if (heavy.length === 0) {
    return `## C6: System Health

Долго работающих процессов нет (порог CPU > 1ч).
`;
  }
  const rows = heavy.map(p => {
    const hours = (p.cpuSeconds / 3600).toFixed(2);
    const ageHours = (p.ageMinutes / 60).toFixed(1);
    return `| ${p.pid} | ${p.name} | ${hours}ч | ${ageHours}ч |`;
  }).join('\n');
  return `## C6: System Health

Топ-3 процессов с CPU > 1ч:

| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
${rows}

⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
`;
}
```

- [ ] **Step B.4: Запустить тесты — PASS**

Run: `npx vitest run tools/system-health.test.mjs`
Expected: PASS, 4 tests green.

- [ ] **Step B.5: Регистрировать блок в `tools/status-md-generator.mjs`**

В верхушке файла добавить импорт:

```js
import { queryRunningProcesses } from './system-health.mjs';
import { computeSystemHealthBlock } from './system-health.mjs';
```

Найти место сборки финального markdown (после `computeAnomalyBlock` / `runCoverageChecker`) и добавить:

```js
const systemHealthBlock = computeSystemHealthBlock(queryRunningProcesses());
```

В строку финальной сборки — вставить `${systemHealthBlock}` после блока Cost/Anomaly, до блока Coverage (или в конец, что соответствует «C6» нумерации).

Конкретное место: после строки с `${anomalyBlock}` (или ближайшего по смыслу), добавить новой строкой `${systemHealthBlock}`.

- [ ] **Step B.6: Failing-test для status-md-generator**

В `tools/status-md-generator.test.mjs` добавить:

```js
import { vi } from 'vitest';

it('STATUS.md output contains C6: System Health block', async () => {
  vi.mock('./system-health.mjs', () => ({
    queryRunningProcesses: () => [
      { pid: 6444, name: 'python', cpuSeconds: 25000, ageMinutes: 800 },
    ],
    computeSystemHealthBlock: (procs) => `## C6: System Health\n\nTEST_MARKER_${procs[0].pid}\n`,
  }));
  // Re-import generator after mock registered
  const { default: generate } = await import('./status-md-generator.mjs');
  // ... либо ручной вызов render-функции, если генератор экспортирует render отдельно
  // Если генератор это CLI-only — пропускаем mock и проверяем что блок попадает в реальный output.
});
```

NB: реальная форма теста зависит от того, экспортирует ли `status-md-generator.mjs` чистую render-функцию или только CLI-side-effect. Если только CLI — упростить до integration-test: запустить `node tools/status-md-generator.mjs --dry-run` (если флаг есть) или mock PowerShell и проверить, что в выходе есть строка `## C6: System Health`.

**Если генератор не экспортирует render отдельно** — добавить экспорт `renderStatusMd(episodes, options)` и тестировать его, не CLI.

- [ ] **Step B.7: Запустить generator и проверить STATUS.md**

Run: `node tools/status-md-generator.mjs && grep -A 3 "## C6" docs/observer/STATUS.md`
Expected: блок «C6: System Health» виден.

- [ ] **Step B.8: Прогнать полный тест-набор generator'а**

Run: `npx vitest run tools/status-md-generator.test.mjs 2>&1 | tail -10`
Expected: ALL PASS.

- [ ] **Step B.9: Commit**

```bash
git add tools/system-health.mjs tools/system-health.test.mjs tools/status-md-generator.mjs tools/status-md-generator.test.mjs docs/observer/STATUS.md
git commit -m "$(cat <<'EOF'
feat(status-md): add C6 System Health block

Surfaces top-3 long-running processes (CPU > 1h) in STATUS.md dashboard.
Closes brain-retro #9 sanity-Q2 — observer was blind to orphan background
processes (e.g. PID 6444 python adr-judge spinning 7h+ undetected).

Read-only PowerShell Get-Process probe with 5s timeout; gracefully degrades
on non-Windows OS (returns empty array).

Closes brain-retro #9 candidate 5.
EOF
)"
```

---

## Финальная проверка после обеих задач

- [ ] **Step F.1: Full tools-test sweep**

Run: `npx vitest run tools/ 2>&1 | tail -20`
Expected: GREEN, 0 regression.

- [ ] **Step F.2: Verify-record для verify-before-push gate**

Run: `node tools/enforce-verify-record.mjs pass`
Expected: sentinel pass file written.

- [ ] **Step F.3: Final push**

Run: `git push origin main`
Expected: 0 leaks, lefthook PASS, push accepted.

---

## Roadmap — оставшиеся фазы (НЕ часть этого плана)

| Фаза | Задачи | Оценка | Когда |
|---|---|---|---|
| **Phase 2** | #6 хук-лимит на override-фразы (>5/день одного типа → AskUser) | ~1.5-2 ч | После Phase 1 |
| **Phase 3** | #7 «добавь/реализуй» → writing-plans · #1 bugfix-слова → systematic-debugging+Pest · #8 «ошибка на боевом» → Sentry · #10 «много однотипного» → coder-agent (все 4 — examples в classifier prompt) | ~4 ч | После Phase 2 |
| **Phase 4** | #9 хук Semgrep на security-edit · #2 measurement enforce-chain-recommendation (block vs override) | ~3 ч | После Phase 3 |
| **Phase 5** | #4 **cost-tracker Stop-hook** (реализовать заново per `2026-05-25-llm-first-router-overhaul.md` Task 20 Step 5 — `~/.claude/runtime/cost-daily.json`) | ~2 ч | После Phase 4 — **отдельная фаза чтоб не забыть** |

Для каждой следующей фазы — отдельный план через `superpowers:writing-plans`, имя файла `docs/superpowers/plans/2026-05-XX-router-hooks-phase{N}-{name}.md`.

---

## Self-Review

**1. Spec coverage:** Phase 1 покрывает кандидатов 3 (Mermaid noise → Task A) и 5 (System Health → Task B). Кандидаты 4 + 6-10 не в этом плане, явно вынесены в Roadmap.

**2. Placeholder scan:** Все Step'ы содержат либо точный код, либо точные команды. Step B.6 содержит честную оговорку о неизвестной форме теста — это **указание исполнителю проверить и упростить**, не placeholder работы.

**3. Type consistency:**

- `parsePowerShellOutput(csv, now)` → возвращает `[{pid, name, cpuSeconds, ageMinutes}]`.
- `queryRunningProcesses()` → возвращает то же.
- `computeSystemHealthBlock(procs)` → принимает то же → string.
- `classificationMap` в analyzer — `{[classification]: string[]}` (унаследовано из существующего кода).

Имена и сигнатуры консистентны между шагами и тестами.

---

## Метаданные плана

- **Версия:** 1.0 от 2026-05-28.
- **Источник:** brain-retro #9 (`docs/observer/notes/2026-05-28-brain-retro-9.md`) + self-retrospect 28.05 (`docs/observer/notes/2026-05-28-self-retrospect.md`).
- **Pre-flight sync:** Pravila §15.2 — нормативка не правится в этом плане (только tools/).
- **Discipline:** этот план — реализация «привычки №2» из self-retrospect (план перед кодом для ≥3-шаговой работы).
