# Router-mentor — E/G warm-up batch (дизайн)

**Дата:** 2026-06-08 · **Ветка:** `worktree-brainrepo` · **Кодовая фраза эпика:** «роутер-наставник».
**Статус:** УТВЕРЖДЁН владельцем (2026-06-08). Источник — реестр хвостов `2026-06-08-router-mentor-loose-ends-registry.md`, блоки E (Doc-gaps/косметика) + G (периферия мозга).
**Анализ-фаза (adversarial, до плана):** прогнан цепочкой `audit-context-building → sharp-edges → variant-analysis` на «не пробьёт ли дыр в М1–М7». Вывод: жёсткие стены (М2/М5/М4/М6) не затронуты; единственный затронутый гейт — observer routing-gate (Машина 0). Внесены 4 правки безопасности: P1 узкий `stripPastedContext` (SE-1, сохранить FP-смещение), P2 counting-replacers по конвейеру sanitize (SE-2, `count==редакции`), P4 `escapeCell` (SE-3, anti-injection в STATUS.md), P5 пиннинг документируемого инварианта (SE-4).

## Цель

Закрыть когерентный «разогревочный» набор мелочей E/G одной сессией: точность наблюдателя (3 кода-фикса), детальный рендер доски обороны, две косметики, одна doc-заметка. Всё инлайн (субагенты/Task/Workflow запрещены), TDD с наблюдаемым RED до prod-правки `tools/*.mjs`, каждый пункт — атомарный коммит, регрессия tools-only ≥ baseline **3174 passed**, **commit-not-push** (push только по слову «пуш»).

## Не-цели (границы)

- **Live-вшивка источников** escape/block-событий в доску — это B-блок (R-09/R-30), НЕ сюда. Доска расширяется как чистый рендер от переданных массивов; `main()` продолжает передавать `[]`.
- Активация хуков, ключи, settings.json — рубильник владельца (A-блок).
- CLAUDE.md в worktree не трогаем (чужая зона).

## Пункты (порядок исполнения)

### 1. G — 4-й FP-класс routing-gate (`tools/observer-routing-detector.mjs`)

**Дефект:** `detectMethodDirected` ([:46](../../../tools/observer-routing-detector.mjs)) флагует `/<node>` где угодно в тексте, включая вставленный фрагмент (PASTED-транскрипт/документ). Цитата → ложный `directed:true`. Этот символ кормит **живой** Stop-gate `routingGateDecision` ([observer-stop-hook.mjs:350](../../../tools/observer-stop-hook.mjs)) — не только телеметрию.

**Граница направления отказа (audit-context + sharp-edges SE-1):** текущий дизайн «conservative-broad» намеренно ошибается в сторону FP (`directed:true` дёшево — контроллер просто добавляет routing-тег). Под-детекция (FN) = тихая потеря governance-сигнала. Поэтому strip обязан быть **узким**, сохраняя FP-смещение для inline-форм.

**Variant-факт:** существующий `stripQuotedContext` ([enforce-rationalization-audit.mjs:51-59](../../../tools/enforce-rationalization-audit.mjs)) стрипает fenced **+ inline-backticks + кавычки**, но **НЕ** blockquote `>`. Его **нельзя reuse** для гейта: он снял бы inline-директиву `использу­й \`/x\`` → FN. При этом реальный FP (pasted-транскрипт) обычно — `>`-цитата, которую существующая функция не снимает.

**Подход:** новая отдельная чистая функция `stripPastedContext(text)` в `observer-routing-detector.mjs` — снимает **только** (а) fenced-блоки ` ```…``` ` и (б) markdown-blockquote-строки (начинающиеся с `>`/`&gt;`). **НЕ** трогает inline-backticks, кавычки, guillemets. Применяется к тексту **до** `/node`-скана и directive-verb-окна (обе формы). Docstring явно фиксирует расхождение с `stripQuotedContext` (противоположное безопасное направление: тот FN-safe, этот FP-safe).

**Приёмка:**
- `/brain-retro` внутри fenced-блока → `directed:false`.
- строка-цитата `> используй brain-retro` → `directed:false`.
- inline `сделай через \`/brainstorming\`` (НЕ в fence/цитате) → `directed:true` (сохраняем FP-смещение — это НЕ FP-баг, это safe-direction).
- голый `/brain-retro` в начале промпта → `directed:true` (без регрессии).
- «запусти brain-retro» (обычная проза) → `directed:true` (без регрессии).
- существующие тесты `observer-routing-detector.test.mjs` — зелёные.

### 2. G — PII double-count перекрытий (`tools/observer-pii-filter.mjs`)

**Дефект:** `countString` ([:66-73](../../../tools/observer-pii-filter.mjs)) прогоняет КАЖДЫЙ паттерн независимо по сырой строке. Токен, матчащий два паттерна (например `sk-…` внутри `Bearer sk-…`), считается дважды → телеметрия `.pii-counters.json` завышена. Сам `sanitizeString` корректен (последовательное потребление через `.replace`).

**Подход (sharp-edges SE-2 — count == реально заредактированное):** считать инструментированием **того же** replace-конвейера, что и `sanitizeString` (тот же порядок регексов, специфичные первыми), а **НЕ** параллельным матчером по сырой строке. Каждый `.replace(re, …)` получает counting-replacer-функцию, инкрементящую счётчик паттерна на фактическую замену; следующий паттерн работает уже по строке после предыдущей замены (перекрытие потреблено первым/самым специфичным). По конструкции `count` равен числу реальных редакций sanitize — под-счёт реальной утечки невозможен (под-счёт хуже пере-счёта для security-телеметрии). `sanitize()` / `sanitizeString()` **не трогаем** — только `countString` переписывается на общий конвейер (вынести общий шаг replace+count, чтобы санитайз и счёт не разъехались).

**Приёмка:**
- `Bearer sk-AAAAAAAAAAAAAAAAAAAA` → суммарный счёт по этому спану = 1 (первый матчнувший паттерн в порядке конвейера), а не 2.
- две РАЗНЫЕ непересекающиеся утечки рядом (`a@b.co +71234567890`) → счёт 2 (не «съесть» вторую).
- строка с одиночными непересекающимися токенами → счётчики без изменений.
- `sanitizeWithCount(...).sanitized` остаётся byte-identical прежнему (санитайз не тронут).
- существующие тесты `observer-pii-filter.test.mjs` — зелёные.

### 3. G — release-class `\bcommit\b` (`tools/observer-transcript-parser.mjs:215`)

**Дефект:** регекс release-класса — `/\bpush\b|\bmerge\b|\bdeploy\b|\bcommit\b|\brelease\b|релиз|тегни/`. Голый `\bcommit\b` бакетит обычный английский коммит как `release`. В этом эпике «commit-not-push» / «let me commit this» вездесущи и почти никогда не релиз. (Русское «коммит» в регексе отсутствует — Cyrillic не матчится, его трогать не надо.)

**Подход:** убрать голый `\bcommit\b` из release-класса; оставить `push`/`merge`/`deploy`/`release`/`релиз`/`тегни`. (Решение владельца: убрать полностью, не «commit + парный сигнал».)

**NB (факт first-match по порядку):** `classifyTask` возвращает первый матч. Пример «commit-not-push» содержит `push` → останется `release` (push доминирует — это корректно). «commit this plan» ловится строкой `planning` (`plan\b`) ещё до release-строки. Изолирующий пример голого commit — `commit the auth changes` (нет push/merge/deploy/release/план/...).

**Приёмка:**
- «commit the auth changes» → было `release`, станет НЕ `release` (проваливается до `other`).
- «push origin main» / «merge feature-branch» / «сделай release v2» → остаются `release` (существующие тесты 947-951 зелёные).
- существующие тесты `observer-transcript-parser.test.mjs` — зелёные.

### 4. E — детальный рендер доски (`tools/status-md-generator.mjs`)

**Дефект:** `computeGuardBoardBlock` ([:42-67](../../../tools/status-md-generator.mjs)) рендерит только счётчики недавних escape/блоков (строки 53-54, 65); детализация — follow-up.

**Подход:** при непустых `recentEscapes`/`recentBlocks` рендерить компактную таблицу (время · действие · причина) под строкой-счётчиком. Пустые массивы → текущий вывод byte-identical. Чистая функция; `main()` продолжает передавать `[]` (live-источник — B-блок, граница соблюдена).

**Guard (sharp-edges SE-3 — injection в STATUS.md):** поля событий идут в markdown-таблицу. Поле с `|`/переводом строки сломало бы таблицу или **подделало строки обороны** (ложное «✅ зарегистрирован» → ложное чувство защиты у владельца). Готового escape-helper'а в `status-md-generator.mjs` нет (variant-факт). Добавить маленький `escapeCell(v)`: `String(v)` → заменить `|`→`\|`, `\r?\n`→` `, cap длины (например 120 символов с `…`). Применять к каждому полю detail-таблицы. Делаем safe-by-construction **сейчас**, до того как B-блок подведёт live-источник.

**Приёмка:**
- given sample-массив escape/block-событий → деталь-таблица отрендерена (колонки время/действие/причина).
- поле `reason` с `|` и `\n` → в выводе экранировано (`\|`, без переноса), таблица цела, лишних строк нет.
- `recentEscapes=[]`, `recentBlocks=[]` → вывод byte-identical текущему (счётчики-строка).
- существующие тесты `status-md-generator.test.mjs` — зелёные.

### 5. E — косметика комментариев (`tools/node-graph.mjs` + `tools/brain-retro-opus-reviewer.mjs`)

**Дефект:** node-graph docstring «Потребляет registry от loadRegistry» неточен (функция берёт plain-registry-объект, не обязательно «от loadRegistry»); reviewer prompt-caching `cache_control` — намеренный no-op на Opus (<4096-токенный префикс), это не задокументировано в коде.

**Подход:** уточнить формулировки. Чтобы честно пройти TDD-gate на comment-only правке `.mjs` — к каждой косметике приколотить 1 пиннинг-ассерт (RED→GREEN), который пинит **именно документируемый инвариант, не трюизм** (sharp-edges SE-4): node-graph — `buildNodeGraph` строит корректный граф из plain-литерала `{nodes, chains}` (документирует развязку от `loadRegistry`); reviewer — structured-prompt возвращает объект с непустым `system`-блоком (документирует, к чему применяется caching). **Variant-факт:** существующие тесты уже зовут `buildNodeGraph(REG)` с plain-объектом — поэтому новый тест именуется по claim'у («accepts plain registry literal, not loadRegistry-bound»), а не дублирует покрытие случайно. Если TDD-gate не срабатывает на comment-only edit — править напрямую без пиннинга.

**Приёмка:**
- docstring/комментарий уточнён в обоих файлах.
- пиннинг-ассерты GREEN и пинят документируемый инвариант (не `expect(true).toBe(true)`-трюизм); либо, при отсутствии срабатывания gate, чистая comment-правка.

### 6. E — DOC-1 escape-awareness note (M7 design spec + loose-ends registry)

**Дефект:** перед активацией М6 (рубильник владельца) не зафиксировано явно: законный escape чтут ТОЛЬКО стена М2 / пол М5 / egress; параллельные стражи (`enforce-router-gate`, `enforce-runtime-write-deny`, судья М4) escape НЕ знают → реальный `force-push` / `.env`-запись не пройдут, пока зоопарк зарегистрирован; полностью «дверь» открывается лишь когда Фаза 8 растворит/научит зоопарк (§10 правило 7).

**Подход:** добавить явный «⚠️ Перед активацией» callout в M7 design spec (`2026-06-08-router-mentor-machine-7-design.md`, рядом с §10 правило 7); пометить DOC-1 ✅ в loose-ends registry.

**Приёмка:** callout присутствует в спеке; DOC-1 помечен ✅ в реестре.

## Дисциплина исполнения

- Каждый код-пункт (1-4): RED-тест(ы) первыми → наблюдаемый провал → правка → GREEN → регрессия tools-only ≥ 3174 → атомарный коммит (msg в `.scratch/*.txt` → `git -C "<wt>" commit -F`).
- vitest: `npx vitest run --root "<wt>/app" --config "<wt>/app/vitest.config.tools.mjs" <фильтр> --reporter dot` (без `cd app`).
- Пункты 5-6: см. п.5 про TDD-gate; п.6 — doc-only.
- Гейт закрытия батча: полная регрессия tools-only ≥ 3174 + verification-before-completion + обновление loose-ends registry (E-пункты → ✅, G-пункты → ✅).
- НЕ requesting-code-review. После реализации — sharp-edges/variant-analysis где уместно, затем regression.
