# Мерж роутера в наставника — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development (inline, RED→GREEN).
> Стена СНЯТА (maintenance) — реализация без цикла печати. Шаги — чекбоксы `- [ ]`.
> Спека: `docs/superpowers/specs/2026-06-13-router-mentor-merge-design.md` (Р1–Р8).

**Goal:** Сделать наставника единым рецензентом — он судит спеку, план И выбор скилов в плане
(зовёт мозг роутера `classify()` как функцию) и заворачивает NO-GO; замечание доходит до контроллера.

**Architecture:** Подход 1. У вердикта наставника появляется явное `decision: GO|NO-GO`. План
структурно объявляет скилы. На ревью плана наставник грузит граф (`loadRegistry`) + зовёт
`classify(цель, граф)` (3-слойный мозг, граф/карточки без изменений), кладёт «рекомендация роутера vs
объявленные скилы» в промпт вердикта. `blocked = wired && (decision==='NO-GO' || !ok)`; на NO-GO суть

- конкретика по скилам доходит до меня; mentor-GO только на чистом GO. Дохлые обёртки роутера не
оживляем. Скил-сверка — только план (gate2); спека (gate1) — по сути + decision.

**Tech Stack:** Node ESM (.mjs), vitest (tools-config). Регрессия:
`npx vitest run --root app --config vitest.config.tools.mjs` (база 3877).

**Порядок:** 1 decision-валидация → 2 decision-промпты → 3 парсер скилов плана → 4 формат
скил-контекста → 5 onPlanWrite зовёт classify → 6 decideMentorObjection (NO-GO заворот) →
7 formatMentorObjection (суть) → 8 прод-проводка main → 9 регрессия.

---

## Task 1: `decision` в валидаторе вердикта наставника

**Files:**

- Modify: `tools/mentor-verdict.mjs` (`validateMentorVerdict` + `MENTOR_VERDICT_SLOTS`)
- Test: `tools/mentor-verdict.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { validateMentorVerdict } from './mentor-verdict.mjs';
describe('validateMentorVerdict — decision (Р7/мерж)', () => {
  const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9 };
  it('decision="GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true); });
  it('decision="NO-GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'NO-GO' }).ok).toBe(true); });
  it('decision отсутствует → невалиден', () => {
    const r = validateMentorVerdict(base);
    expect(r.ok).toBe(false); expect(r.missingSlots).toContain('decision');
  });
  it('decision мусор → невалиден', () => {
    expect(validateMentorVerdict({ ...base, decision: 'maybe' }).ok).toBe(false);
  });
});
```

- [ ] **Step 2: Прогон — FAIL.** Run: `npx vitest run --root app --config vitest.config.tools.mjs mentor-verdict`

- [ ] **Step 3: Реализация.** В `mentor-verdict.mjs`: добавить `'decision'` в `MENTOR_VERDICT_SLOTS`;
  в `validateMentorVerdict` добавить проверку:

```js
if (verdict.decision !== 'GO' && verdict.decision !== 'NO-GO') missingSlots.push('decision');
```

(вставить в теле `validateMentorVerdict` после проверки confidence, перед `return`.)

- [ ] **Step 4: Прогон — PASS.** Та же команда. Сверить, что соседние тесты mentor-verdict не упали
  (фикстуры GOOD-вердиктов в других тестах — добавить им `decision:'GO'`, если упадут).

---

## Task 2: `decision` в промптах наставника (план + спека)

**Files:**

- Modify: `tools/mentor-verdict.mjs` (`buildMentorVerdictPrompt`, `buildMentorSpecVerdictPrompt`)
- Test: `tools/mentor-verdict.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { buildMentorVerdictPrompt, buildMentorSpecVerdictPrompt } from './mentor-verdict.mjs';
describe('промпты просят decision', () => {
  it('промпт плана требует decision GO/NO-GO', () => {
    const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
    expect(p.system).toMatch(/decision/i);
    expect(p.system).toMatch(/NO-GO/);
  });
  it('промпт спеки требует decision GO/NO-GO', () => {
    const p = buildMentorSpecVerdictPrompt({ specContent: '# с' });
    expect(p.system).toMatch(/decision/i);
    expect(p.system).toMatch(/NO-GO/);
  });
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.** В обоих билдерах в `system`-массив добавить строку (перед строкой «ВЫВОД — строго JSON…»), и в саму строку «ВЫВОД» добавить слот `decision`:

```js
'Вынеси РЕШЕНИЕ: decision="GO" если можно реализовывать, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).',
```

И в строке формата вывода добавить: `decision ("GO"|"NO-GO")` к перечню слотов.

- [ ] **Step 4: PASS.**

---

## Task 3: парсер объявленных скилов плана

**Files:**

- Create: `tools/plan-skills.mjs`
- Test: `tools/plan-skills.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { parsePlanSkills } from './plan-skills.mjs';
describe('parsePlanSkills', () => {
  const md = ['# План', '```skills-json', '["executing-plans","test-driven-development"]', '```', '## Цель', 'x'].join('\n');
  it('достаёт список скилов из ```skills-json блока', () => {
    expect(parsePlanSkills(md)).toEqual(['executing-plans', 'test-driven-development']);
  });
  it('нет блока → пустой массив', () => { expect(parsePlanSkills('# План\nтекст')).toEqual([]); });
  it('битый JSON → пустой массив (fail-safe)', () => {
    expect(parsePlanSkills('```skills-json\n[не json\n```')).toEqual([]);
  });
  it('не-массив / не-строки отфильтрованы', () => {
    expect(parsePlanSkills('```skills-json\n["ok", 5, null, "two"]\n```')).toEqual(['ok', 'two']);
  });
});
```

- [ ] **Step 2: FAIL** (модуль отсутствует).

- [ ] **Step 3: Реализация.**

```js
#!/usr/bin/env node
/** Парсер объявленных в плане скилов (мерж роутер↔наставник). Зеркало parseVerifiedContext:
 *  ищет fenced-блок ```skills-json со списком строк. Нет/битый → []. */
export function parsePlanSkills(content) {
  const m = String(content ?? '').match(/```skills-json\s*\n([\s\S]*?)\n```/i);
  if (!m) return [];
  let arr;
  try { arr = JSON.parse(m[1]); } catch { return []; }
  if (!Array.isArray(arr)) return [];
  return arr.filter((s) => typeof s === 'string' && s.trim());
}
```

- [ ] **Step 4: PASS.** Run: `npx vitest run --root app --config vitest.config.tools.mjs plan-skills`

---

## Task 4: формат скил-контекста для промпта наставника

**Files:**

- Modify: `tools/mentor-seam.mjs` (добавить `renderSkillContext`)
- Test: `tools/mentor-seam.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { renderSkillContext } from './mentor-seam.mjs';
describe('renderSkillContext', () => {
  it('содержит объявленные скилы и рекомендацию роутера', () => {
    const s = renderSkillContext({ declared: ['executing-plans'], recommendedChain: ['systematic-debugging', 'test-driven-development'] });
    expect(s).toMatch(/executing-plans/);
    expect(s).toMatch(/systematic-debugging/);
    expect(s).toMatch(/скил/i);
  });
  it('classify недоступен (null) → маркер «рекомендация недоступна», не пусто', () => {
    const s = renderSkillContext({ declared: ['x'], recommendedChain: null });
    expect(s).toMatch(/недоступн/i);
  });
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.** Добавить в `mentor-seam.mjs`:

```js
/** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы +
 *  рекомендация роутера. recommendedChain=null → классификатор недоступен (не сверять). */
export function renderSkillContext({ declared = [], recommendedChain = null } = {}) {
  const dec = declared.length ? declared.join(', ') : '(не объявлены)';
  const rec = recommendedChain === null
    ? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)'
    : (Array.isArray(recommendedChain) && recommendedChain.length ? recommendedChain.join(', ') : '(роутер ничего не порекомендовал)');
  return `--- СКИЛЫ ---\nОбъявлены в плане: ${dec}\nРекомендация роутера: ${rec}\n`
    + 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.';
}
```

- [ ] **Step 4: PASS.** + подключить `renderSkillContext` в `buildMentorVerdictPrompt` (план): добавить
  в `user`-массив `skillContext ? skillContext : ''` (новый параметр `skillContext` билдера). Тест на
  это — в Task 5 (через onPlanWrite). Спеку (`buildMentorSpecVerdictPrompt`) НЕ трогаем (скилы — план).

---

## Task 5: `onPlanWrite` зовёт `classify` и кладёт скил-контекст в вердикт

**Files:**

- Modify: `tools/on-plan-write.mjs` (`onPlanWrite`: новые инъекции `classifyImpl`, `registry`,
  `declaredSkills`; собрать `skillContext`, прокинуть в `runMentorVerdictImpl`)
- Modify: `tools/mentor-verdict.mjs` (`runMentorVerdict` + `buildMentorVerdictPrompt` принимают `skillContext`)
- Test: `tools/on-plan-write.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { onPlanWrite } from './on-plan-write.mjs';
describe('onPlanWrite — скил-сверка через classify (мерж)', () => {
  const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' };
  it('зовёт classifyImpl и кладёт рекомендацию в промпт вердикта', async () => {
    let classified = null; let capturedUser = null;
    const r = await onPlanWrite({
      planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }],
      declaredSkills: ['executing-plans'],
      classifyImpl: async (goal) => { classified = goal; return { recommended_chain: ['systematic-debugging'] }; },
      registry: {},
      llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; },
      planGoal: 'починить парсер',
    });
    expect(classified).toBe('починить парсер');
    expect(capturedUser).toMatch(/systematic-debugging/);
    expect(capturedUser).toMatch(/executing-plans/);
    expect(r.ok).toBe(true);
  });
  it('classifyImpl бросил → вердикт без скил-сверки (маркер недоступности), не падает', async () => {
    let capturedUser = null;
    const r = await onPlanWrite({
      planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }],
      declaredSkills: ['x'],
      classifyImpl: async () => { throw new Error('классификатор недоступен'); },
      registry: {},
      llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; },
      planGoal: 'g',
    });
    expect(r.ok).toBe(true);
    expect(capturedUser).toMatch(/недоступн/i);
  });
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.**
  - В `on-plan-write.mjs` `onPlanWrite` принять новые параметры `classifyImpl=null`, `registry=null`,
    `declaredSkills=[]`, `planGoal=''`. Перед `runMentorVerdictImpl` собрать рекомендацию:

```js
let recommendedChain = null;
if (typeof classifyImpl === 'function') {
  try { const c = await classifyImpl(planGoal, registry); recommendedChain = (c && c.recommended_chain) || (c && c.recommended_node ? [c.recommended_node] : []); }
  catch { recommendedChain = null; } // fail-safe: classify упал → не сверяем скилы
}
const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain });
```

  (импортировать `renderSkillContext` из `./mentor-seam.mjs`.) Прокинуть `skillContext` в
  `runMentorVerdictImpl({ ..., skillContext })`.

- В `mentor-verdict.mjs` `runMentorVerdict` принять `skillContext=null` и передать в
    `buildMentorVerdictPrompt({ ..., skillContext })`; билдер добавляет `skillContext` в `user` (Task 4 Step 4).

- [ ] **Step 4: PASS.** Run: `npx vitest run --root app --config vitest.config.tools.mjs on-plan-write mentor-verdict`

---

## Task 6: `decideMentorObjection` — заворот на NO-GO, mentor-GO только на GO

**Files:**

- Modify: `tools/enforce-mentor-on-plan-write.mjs` (`decideMentorObjection`)
- Test: `tools/enforce-mentor-on-plan-write.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
import { decideMentorObjection } from './enforce-mentor-on-plan-write.mjs';
describe('decideMentorObjection — decision (мерж/Р7)', () => {
  const noGo = { ran: true, wired: true, ok: true, verdict: { decision: 'NO-GO', recommendation: 'добавь systematic-debugging' } };
  const go = { ran: true, wired: true, ok: true, verdict: { decision: 'GO' } };
  it('содержательный NO-GO (ok=true, decision=NO-GO) → block + recordMentorGo:false', () => {
    const d = decideMentorObjection({ res: noGo, planContent: '# п', n: 1 });
    expect(d.block).toBe(true);
    expect(d.recordMentorGo).toBe(false);
  });
  it('GO (decision=GO) → block:false + recordMentorGo:true', () => {
    const d = decideMentorObjection({ res: go, planContent: '', n: 0 });
    expect(d.block).toBe(false);
    expect(d.recordMentorGo).toBe(true);
  });
  it('сломанный вердикт (wired && !ok) → block (как раньше)', () => {
    expect(decideMentorObjection({ res: { ran: true, wired: true, ok: false }, planContent: '', n: 0 }).block).toBe(true);
  });
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.** В `decideMentorObjection` (после degraded-ветки) заменить вычисление
  `blocked`/`recordMentorGo`:

```js
const decision = res && res.verdict && res.verdict.decision;
const blocked = !!(res && res.wired === true && (decision === 'NO-GO' || res.ok !== true));
if (!blocked) {
  const recordMentorGo = !!(res && res.wired === true && res.ok === true && decision === 'GO');
  return { block: false, recordMentorGo };
}
const message = n >= MENTOR_ESCALATE_AFTER
  ? buildMentorArbitrationMessage(res, String(planContent ?? ''), n)
  : buildObjectionFeedback({ side: 'mentor', text: formatMentorObjection(res) });
return { block: true, recordMentorGo: false, message };
```

- [ ] **Step 4: PASS** + соседние тесты decideMentorObjection не сломаны (поправить фикстуры —
  старые GO-фикстуры добавить `verdict:{decision:'GO'}`, NO-GO — `decision:'NO-GO'`).

---

## Task 7: `formatMentorObjection` доносит СУТЬ (recommendation + reasoning + пункты)

**Files:**

- Modify: `tools/objection-format.mjs` (`formatMentorObjection`)
- Test: `tools/objection-format.test.mjs`

- [ ] **Step 1: Падающий тест.**

```js
it('содержательный NO-GO → суть: recommendation + reasoning + пункты', () => {
  const r = { ok: true, wired: true, verdict: { decision: 'NO-GO', recommendation: 'добавь systematic-debugging',
    reasoning: 'это отладка бага', plan_points_addressed: ['шаг 2: скил не тот'] } };
  const s = formatMentorObjection(r);
  expect(s).toMatch(/systematic-debugging/);
  expect(s).toMatch(/отладка бага/);
  expect(s).toMatch(/шаг 2/);
});
it('GO → пустая строка (не заворот)', () => {
  expect(formatMentorObjection({ ok: true, wired: true, verdict: { decision: 'GO' } })).toBe('');
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.** Переписать `formatMentorObjection(r)`:

```js
export function formatMentorObjection(r) {
  if (!r || r.wired !== true) return '';
  const v = r.verdict || {};
  const isNoGo = v.decision === 'NO-GO' || r.ok !== true;
  if (!isNoGo) return '';
  const lines = ['Замечание наставника:'];
  if (typeof v.recommendation === 'string' && v.recommendation.trim()) lines.push(`Что править: ${v.recommendation.trim()}`);
  if (typeof v.reasoning === 'string' && v.reasoning.trim()) lines.push(`Разбор: ${v.reasoning.trim()}`);
  const pts = Array.isArray(v.plan_points_addressed) ? v.plan_points_addressed.filter((p) => typeof p === 'string' && p.trim()) : [];
  if (pts.length) lines.push('По пунктам:', ...pts.map((p) => `— ${p}`));
  if (typeof r.reason === 'string' && r.reason.trim()) lines.push(`(${r.reason.trim()})`);
  return lines.length > 1 ? lines.join('\n') : '';
}
```

- [ ] **Step 4: PASS** + соседние тесты objection-format не сломаны (старые фикстуры mentor —
  добавить `verdict.decision` где нужно).

---

## Task 8: прод-проводка — наставник грузит граф и зовёт classify в main()

**Files:**

- Modify: `tools/enforce-mentor-on-plan-write.mjs` (`main` + `runMentorOnPlanWrite`: прокинуть
  `classifyImpl`/`registry`/`declaredSkills`/`planGoal` в `onPlanWrite`)
- Test: `tools/enforce-mentor-on-plan-write.test.mjs`

- [ ] **Step 1: Падающий тест** — `runMentorOnPlanWrite` для плана прокидывает `declaredSkills`
  (из `parsePlanSkills(content)`) и зовёт инъецированный `classifyImpl`:

```js
it('runMentorOnPlanWrite (план): парсит скилы + зовёт classifyImpl', async () => {
  let classifyCalled = false;
  const PLAN = ['# План','```skills-json','["executing-plans"]','```','## Цель','чинить','```steps-json','[{"n":1,"op":"Edit","object":"x","ref":"D1"}]','```','```verified-context-json','[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"x:1","anchor":"я"}]','```'].join('\n');
  const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/2026-06-13-t.md', content: PLAN }, session_id: 'S1' };
  const d = deps({ classifyImpl: async () => { classifyCalled = true; return { recommended_chain: ['systematic-debugging'] }; } });
  const r = await runMentorOnPlanWrite(ev, d);
  expect(r.ran).toBe(true);
  expect(classifyCalled).toBe(true);
});
```

  (в `deps()` хелпере теста добавить дефолтные `classifyImpl: async () => ({ recommended_chain: [] })`,
  `registryImpl: () => ({})`.)

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.**
  - `runMentorOnPlanWrite`: принять `classifyImpl`, `registryImpl`; в plan-ветке вычислить
    `declaredSkills = parsePlanSkills(content)`, `planGoal = extractGoalFromPlan(content)` (мини-хелпер:
    секция `## Цель`/`## Goal` или первый непустой абзац — зеркало `extractGoal` судьи; вынести в
    `plan-skills.mjs` как `extractPlanGoal`), и передать `classifyImpl`, `registry: registryImpl?.()`,
    `declaredSkills`, `planGoal` в `onPlanWrite`.
  - `main()`: впрыснуть прод-реализации:

```js
import { loadRegistry } from './registry-load.mjs';
import { classify } from './router-classifier.mjs';
import { resolveMentorLlmKey } from './mentor-gate-config.mjs';
// ...в вызове runMentorOnPlanWrite(...):
classifyImpl: async (goal, registry) => classify(goal, registry, {}),
registryImpl: () => { try { return loadRegistry({ useCache: false }); } catch { return null; } },
```

  (classify сам использует свой транспорт/ключ; на сбой — onPlanWrite ловит → fail-safe без скил-сверки.)

- [ ] **Step 4: PASS.** Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-mentor-on-plan-write`

---

## Task 9: регрессия + финал

- [ ] **Step 1:** Run: `npx vitest run --root app --config vitest.config.tools.mjs`
  Expected: весь набор зелёный (база 3877 + новые тесты), 0 регрессий. RED → systematic-debugging.

- [ ] **Step 2:** Отдать владельцу для возврата стены (способ-B settings.json) + повторного
  сквозного смоука: план с неуместными скилами → наставник classify → NO-GO «скилы не те» → замечание
  доходит до контроллера + L1. *(Шаг владельца — settings.json/перезапуск/смоук.)*

---

## Self-Review

**Spec coverage:** §3.1 decision → Task 1-2,6; §3.2 план объявляет скилы → Task 3; §3.3 наставник
зовёт classify + скил-сверка (план-only) → Task 4-5,8; §3.4 заворот/доставка/mentor-GO → Task 6-7;
§3.5 не трогаем classify/граф/судью → соблюдено (только новый вызыватель); §5 fail-safe classify →
Task 5 (тест) + Task 8; §6 тесты → каждая задача TDD + Task 9 регрессия. ✅
**Placeholders:** код в каждом шаге реальный; «точный формат блока скилов» закрыт Task 3 (```skills-json).
**Type consistency:** `parsePlanSkills(content)→string[]`; `renderSkillContext({declared,recommendedChain})→string`;
`decideMentorObjection({res,planContent,n})→{block,recordMentorGo,message?}`; `classifyImpl(goal,registry)→{recommended_chain?,recommended_node?}`;
`decision` ∈ {GO,NO-GO} единообразно. Скил-сверка — только план (gate2), спека не трогается (§3.3).

**Главный риск:** classify — живой LLM-вызов на ревью плана (+стоимость/латентность); на сбой —
fail-safe (судим план без скил-сверки, не ложный NO-GO).
