# Машина 3 / под-план 3-D — Движок роутера — план реализации

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans.
> **NB сборки:** код строит контроллер по TDD (субагент не проходит TDD-гейт).

**Goal:** думающая голова роутера — выбор скила рассуждением по ПОЛНОМУ графу+каталогу (3-B) + контрактам (3-A), с вшитым брейнстормом, видимой трассой 5.1 (структурные слоты), L-ядром (L1/L3/L4/L5/L7), детерминированным риск-фильтром, ценой-разделителем (6.1) — где РАССУЖДЕНИЕ у модели (вызов мокается), а все ПРОВЕРКИ/ЗАЩИТЫ вокруг тупо-механические и покрыты тестами («умный не баран»). Плюс стендовый модуль дерева-указателя (волны D6/OQ1).

**Architecture:** идиома `tools/router-classifier.mjs` (`classify(prompt, registry, {llmCall})` + `callAnthropicAPI({system,user})` ephemeral-cache + `parse*`). `tools/router-engine.mjs`: чистый `buildRouterPrompt`→{system,user} (граф+каталог в system для кэша, 6.2-исключение) + `parseRouterResponse` + механические `validateTrace`/`groundTrace`(реюз 3-B resolveNode)/`validateLevelSkip`/`detectHighRisk`/`cheaperOf` + оркестратор `runRouter({llmCall})` (вызов инъектируется/мокается). `tools/step-pointer.mjs`: дерево указателя шага (волны), стендово.

**Tech Stack:** Node.js ESM, vitest tools-only. Реюз `node-graph` (resolveNode), `coverage-machine` (готовности — стык, не вызываем здесь жёстко).

---

## ⚠️ Контекст исполнения (как 3-A..3-C)
- Git только `git -C "<worktree>"`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd`.
- Тест-файлы — в `<worktree>\tools\`, ЦЕЛИКОМ через Write. TDD-гейт: Read плана прямыми слэшами + тест + Bash-RED, потом прод.

## Границы 3-D (что НЕ здесь — записано в журнал вопросов)
- **K4-поправка к `enforce-supreme-gate.decideMode`** (узкое Write-исключение артефакта) — **НЕ активируется** (канон §1: только ПОСЛЕ K1/Машины 4). 3-D `decideMode` не трогает.
- **Live-wiring** (регистрация в settings.json + реальные Sonnet-вызовы) — шаг владельца + режим D28 «тихо». Здесь llmCall МОКается; живой `callAnthropicAPI` готов, не регистрируется.
- **Перенос волн в живой `main()` Машины 2** — НЕ делается (трогает рабочий код). Дерево-указатель строится СТЕНДОВО (модуль готов к замене линейного позже).
- **Оценщик постфактум** (правдивость заявленной категории, D31) → Машина 4.

---

## Структура файлов
**Создаём:**
- `tools/router-engine.mjs` — `detectHighRisk`, `validateLevelSkip`, `cheaperOf` (Task 1); `validateTrace`, `groundTrace` (Task 2); `buildRouterPrompt`, `parseRouterResponse`, `runRouter` (Task 3).
- `tools/step-pointer.mjs` — дерево указателя шага (Task 4).
- Тесты: `tools/router-engine.test.mjs`, `tools/step-pointer.test.mjs`, `tools/m3d-router-invariants.test.mjs`.

**Порядок:** риск/skip/цена (Task 1) → трасса+заземление (Task 2) → промпт+парс+оркестратор-мок (Task 3) → дерево-указатель (Task 4) → инварианты+регрессия (Task 5).

---

## Task 1: Детерминированный риск (6.1) + валидатор пропуска уровня (6.2) + цена-разделитель (6.1)

**Files:** Create `tools/router-engine.mjs`, `tools/router-engine.test.mjs`

- [ ] **Step 1: Падающий тест** — `tools/router-engine.test.mjs`

```js
import { describe, it, expect } from 'vitest';
import { detectHighRisk, validateLevelSkip, LEVEL_SKIP_CATEGORIES, cheaperOf } from './router-engine.mjs';

describe('detectHighRisk (6.1 — ДЕТЕРМИНИРОВАННО, LLM-оценка только совет)', () => {
  it('разрушительная операция → high', () => {
    expect(detectHighRisk({ op: 'Bash', command: 'rm -rf x' }).high).toBe(true);
    expect(detectHighRisk({ op: 'Bash', command: 'git push --force' }).high).toBe(true);
  });
  it('прод-выкат → high', () => {
    expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
  });
  it('многошаговость над порогом → high', () => {
    expect(detectHighRisk({ op: 'Skill', stepCount: 9 }).high).toBe(true);
  });
  it('инъецированный sensitive-флаг → high (портативно, без зашитых путей)', () => {
    expect(detectHighRisk({ op: 'Edit', sensitive: true }).high).toBe(true);
  });
  it('обычное чтение → не high', () => {
    expect(detectHighRisk({ op: 'Read' }).high).toBe(false);
  });
  it('reasons перечисляет причины', () => {
    expect(detectHighRisk({ op: 'Bash', command: 'DROP TABLE x' }).reasons.length).toBeGreaterThan(0);
  });
});

describe('validateLevelSkip (6.2 — только закрытый список категорий)', () => {
  it('категория из закрытого списка → ok', () => {
    expect(validateLevelSkip(LEVEL_SKIP_CATEGORIES[0]).ok).toBe(true);
  });
  it('свободный текст → отклонён', () => {
    expect(validateLevelSkip('не хочу заморачиваться').ok).toBe(false);
  });
  it('пусто → отклонён', () => {
    expect(validateLevelSkip('').ok).toBe(false);
    expect(validateLevelSkip(null).ok).toBe(false);
  });
});

describe('cheaperOf (6.1 — цена РАЗДЕЛИТЕЛЬ равноценных, не глушилка)', () => {
  it('при равном качестве берёт дешевле', () => {
    const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 5 }, { skill: 'B', qualityRank: 1, costRank: 2 }]);
    expect(r.skill).toBe('B');
  });
  it('лучшее качество побеждает несмотря на цену (цена НЕ важнее правильности)', () => {
    const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 9 }, { skill: 'B', qualityRank: 2, costRank: 1 }]);
    expect(r.skill).toBe('A');
  });
  it('пусто → null', () => { expect(cheaperOf([])).toBe(null); });
});
```

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

- [ ] **Step 3: Реализация** — `tools/router-engine.mjs`

```js
#!/usr/bin/env node
/**
 * router-engine — думающая голова роутера (3-D). РАССУЖДЕНИЕ — у модели (llmCall
 * инъектируется/мокается, идиома router-classifier); все ПРОВЕРКИ/ЗАЩИТЫ вокруг —
 * тупо-механические и покрыты тестами («умный не баран»). Группы 1/2/5/6 + look-ahead
 * + L-ядро + риск-фильтр W1+W2 + деньги 6.1/6.2. K4-поправку к стене и live-wiring
 * НЕ трогает (см. журнал вопросов: только после Машины 4).
 */
import { resolveNode } from './node-graph.mjs';

// Универсальные разрушительные глаголы (портативно — не project-хардкод путей).
const DESTRUCTIVE_RE = /\b(rm|rmdir|drop|delete|truncate|--force|force-push|reset --hard|migrate:fresh|format)\b|push\s+--force/i;

/**
 * 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/
 * инъецированному sensitive-флагу). LLM-оценка риска — ТОЛЬКО совет, НЕ триггер
 * блока (жёсткий блок нельзя вешать на догадку модели). stepThreshold — ручка.
 */
export function detectHighRisk({ op, command = '', prodDeploy = false, stepCount = 0, sensitive = false } = {}, { stepThreshold = 7 } = {}) {
  const reasons = [];
  if (DESTRUCTIVE_RE.test(command)) reasons.push('разрушительная операция');
  if (prodDeploy) reasons.push('прод-выкат');
  if (stepCount >= stepThreshold) reasons.push(`многошаговость (${stepCount}≥${stepThreshold})`);
  if (sensitive) reasons.push('чувствительная зона (инъецированный флаг)');
  return { high: reasons.length > 0, reasons };
}

// 6.2 — закрытый список категорий пропуска уровня (свободный текст отклоняется).
export const LEVEL_SKIP_CATEGORIES = Object.freeze(['atomic', 'trivial', 'obvious-single-path', 'no-skill-needed']);

/** 6.2 — пропуск уровня только ЯВНОЙ категорией из закрытого списка. */
export function validateLevelSkip(category) {
  const ok = typeof category === 'string' && LEVEL_SKIP_CATEGORIES.includes(category.trim());
  return { ok, allowed: LEVEL_SKIP_CATEGORIES };
}

/**
 * 6.1 — цена как РАЗДЕЛИТЕЛЬ равноценных: сначала лучшее качество (min qualityRank),
 * среди равных по качеству — min costRank. Цена НЕ важнее правильности.
 */
export function cheaperOf(candidates) {
  if (!Array.isArray(candidates) || candidates.length === 0) return null;
  return [...candidates].sort((a, b) =>
    (a.qualityRank - b.qualityRank) || (a.costRank - b.costRank))[0];
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" add tools/router-engine.mjs tools/router-engine.test.mjs` + commit `"feat(m3-d): router-engine — deterministic risk (6.1) + level-skip (6.2) + price discriminator"`

---

## Task 2: Видимая трасса 5.1 — validateTrace (слоты, 6.2 A) + groundTrace (ОВ-Д2 C)

**Files:** Modify `tools/router-engine.mjs`, `tools/router-engine.test.mjs`

- [ ] **Step 1: Падающие тесты (добавить)**

```js
import { validateTrace, groundTrace, TRACE_SLOTS } from './router-engine.mjs';
import { buildNodeGraph } from './node-graph.mjs';

const GRAPH = buildNodeGraph({ nodes: [
  { id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' },
  { id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' },
], chains: {} });

const FULL_TRACE = {
  candidates: ['writing-plans', 'discovery-interview'],
  chosen: 'writing-plans',
  why_chosen: 'multi-step feature plan',
  twins_considered: 'discovery-interview — отброшен: цель не интервью',
  confidence: 0.8,
};

describe('validateTrace (5.1 структурные слоты; пустой слот = красный флаг, §6.2 A)', () => {
  it('полная трасса → ok', () => {
    expect(validateTrace(FULL_TRACE)).toEqual({ ok: true, missingSlots: [] });
  });
  it('пустой слот → невалидно + перечень', () => {
    const r = validateTrace({ ...FULL_TRACE, why_chosen: '' });
    expect(r.ok).toBe(false); expect(r.missingSlots).toContain('why_chosen');
  });
  it('нет кандидатов → невалидно', () => {
    expect(validateTrace({ ...FULL_TRACE, candidates: [] }).ok).toBe(false);
  });
  it('TRACE_SLOTS перечисляет обязательные слоты', () => {
    expect(TRACE_SLOTS).toContain('chosen');
  });
});

describe('groundTrace (ОВ-Д2 C — все кандидаты в реальные узлы; выдумка → отклонение)', () => {
  it('все кандидаты резолвятся → grounded', () => {
    expect(groundTrace(FULL_TRACE, GRAPH)).toMatchObject({ grounded: true, invented: [] });
  });
  it('выдуманный кандидат → not grounded + перечень', () => {
    const r = groundTrace({ ...FULL_TRACE, candidates: ['writing-plans', 'elasticsearch-magic'] }, GRAPH);
    expect(r.grounded).toBe(false); expect(r.invented).toContain('elasticsearch-magic');
  });
  it('выдуманный chosen → not grounded', () => {
    const r = groundTrace({ ...FULL_TRACE, chosen: 'made-up' }, GRAPH);
    expect(r.grounded).toBe(false);
  });
});
```

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

- [ ] **Step 3: Реализация (дописать)**

```js
// 5.1 — обязательные структурные слоты трассы (схема=метод, §6.2 A).
export const TRACE_SLOTS = Object.freeze(['candidates', 'chosen', 'why_chosen', 'twins_considered', 'confidence']);

/**
 * Механический валидатор трассы (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B, аналог A0):
 * пустой/отсутствующий слот = красный флаг. candidates должен быть непустым массивом,
 * confidence — число, остальные — непустые строки.
 */
export function validateTrace(trace) {
  const missingSlots = [];
  if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...TRACE_SLOTS] };
  if (!Array.isArray(trace.candidates) || trace.candidates.length === 0) missingSlots.push('candidates');
  for (const slot of ['chosen', 'why_chosen', 'twins_considered']) {
    if (typeof trace[slot] !== 'string' || !trace[slot].trim()) missingSlots.push(slot);
  }
  if (typeof trace.confidence !== 'number') missingSlots.push('confidence');
  return { ok: missingSlots.length === 0, missingSlots };
}

/**
 * ОВ-Д2 / §6.3 C — заземление: каждый кандидат И выбранный обязаны резолвиться в
 * реальный узел графа (3-B resolveNode); выдумка → grounded=false + перечень invented.
 */
export function groundTrace(trace, graph) {
  const invented = [];
  const check = (ref) => { if (resolveNode(graph, ref) === null) invented.push(ref); };
  for (const cand of (trace?.candidates || [])) check(cand);
  if (trace?.chosen) check(trace.chosen);
  return { grounded: invented.length === 0, invented };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" commit -am "feat(m3-d): visible trace 5.1 validator + groundTrace (ОВ-Д2)"`

---

## Task 3: Построитель промпта + парсер + оркестратор runRouter (llmCall мокается)

**Files:** Modify `tools/router-engine.mjs`, `tools/router-engine.test.mjs`

- [ ] **Step 1: Падающие тесты (добавить)**

```js
import { buildRouterPrompt, parseRouterResponse, runRouter } from './router-engine.mjs';

const CATALOG = { nodes: [
  { id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' },
] };

describe('buildRouterPrompt (чистая, {system,user}; граф+каталог в system для кэша 6.2)', () => {
  it('детерминирована: тот же вход → тот же текст', () => {
    const a = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
    const b = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
    expect(a.system).toBe(b.system);
    expect(a.user).toBe(b.user);
  });
  it('system несёт вшитый брейнсторм + слоты трассы; user — задачу', () => {
    const { system, user } = buildRouterPrompt({ prompt: 'add feature X', graph: GRAPH, catalog: CATALOG });
    expect(system).toMatch(/вариант|brainstorm|2.?3/i);
    expect(system).toMatch(/candidates/);
    expect(user).toMatch(/add feature X/);
  });
});

describe('parseRouterResponse', () => {
  it('парсит JSON-трассу', () => {
    const t = parseRouterResponse('```json\n{"chosen":"writing-plans","candidates":["writing-plans"],"why_chosen":"x","twins_considered":"y","confidence":0.9}\n```');
    expect(t.chosen).toBe('writing-plans');
  });
  it('мусор → null', () => { expect(parseRouterResponse('не json')).toBe(null); });
});

describe('runRouter (оркестратор; llmCall мокается)', () => {
  const okTrace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'feature', twins_considered: 'none', confidence: 0.85 };
  it('валидная заземлённая трасса с уверенностью → ok-результат', async () => {
    const r = await runRouter({ prompt: 'add feature', graph: GRAPH, llmCall: async () => okTrace });
    expect(r.ok).toBe(true); expect(r.trace.chosen).toBe('writing-plans'); expect(r.abstain).toBe(false);
  });
  it('пустой слот трассы → невалидно (мех. флаг, не результат)', async () => {
    const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, why_chosen: '' }) });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|trace|missing/i);
  });
  it('выдуманный кандидат → отклонение (заземление)', async () => {
    const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, candidates: ['made-up'], chosen: 'made-up' }) });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/выдум|invent|заземл/i);
  });
  it('низкая уверенность → воздержание (5.2), не угадывание', async () => {
    const r = await runRouter({ prompt: 'x', graph: GRAPH, confidenceThreshold: 0.7, llmCall: async () => ({ ...okTrace, confidence: 0.3 }) });
    expect(r.abstain).toBe(true);
  });
  it('llmCall вернул мусор → ok=false (не падает)', async () => {
    const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => null });
    expect(r.ok).toBe(false);
  });
});
```

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

- [ ] **Step 3: Реализация (дописать)**

```js
/**
 * Построитель промпта (чистый, {system,user} — идиома buildClassifierPromptStructured).
 * system: статичные инструкции роутера + вшитый брейнсторм (2–3 варианта → спор → выбор)
 * + L-ядро (L1 образец / L3 дефолт-на-вето / L4 порядок по влиянию / L5 ограничения-
 * пре-мортем / L7 критерии приёмки) + схема слотов трассы 5.1 + ПОЛНЫЙ граф+каталог
 * (6.2-исключение: в system для кэша). user: задача (+план/прогресс) — волатильное.
 */
export function buildRouterPrompt({ prompt = '', plan = null, graph = null, catalog = null } = {}) {
  const nodesForCtx = (catalog && catalog.nodes) || (graph && graph.nodes) || [];
  const catalogText = nodesForCtx.map((n) => `${n.id || ''} ${n.slug || ''} — ${n.capabilities || n.name || ''}`).join('\n');
  const system = [
    'Ты — РОУТЕР-НАСТАВНИК. Выбираешь навык по СМЫСЛУ задачи рассуждением (ключевые слова сняты).',
    'МЕТОД (вшитый брейнсторм): придумай 2–3 варианта-кандидата с разных углов графа → поспорь с собой (почему не сосед-близнец? а если я неправ?) → выбери лучший с обоснованием.',
    'L-ядро: L1 предложи дешёвый образец до полной стройки; L3 давай разумный дефолт-на-вето вместо открытого вопроса; L4 порядок решений по влиянию (фундамент раньше мелочи); L5 ограничения как пре-мортем; L7 объяви критерий приёмки заранее.',
    'ВЫВОД — строго JSON-трасса со слотами: candidates (массив реальных узлов графа), chosen, why_chosen, twins_considered (почему не близнец), confidence (0..1). Пустой слот недопустим.',
    'Кандидаты ОБЯЗАНЫ быть реальными узлами из каталога ниже — не выдумывай.',
    '--- ГРАФ+КАТАЛОГ УЗЛОВ (100%) ---',
    catalogText,
  ].join('\n');
  const user = [
    `ЗАДАЧА: ${prompt}`,
    plan ? `ПЛАН (прогресс): ${typeof plan === 'string' ? plan : JSON.stringify(plan)}` : '',
  ].filter(Boolean).join('\n');
  return { system, user };
}

/** Парсер ответа роутера → трасса-объект | null (терпимый к ```json fence). */
export function parseRouterResponse(text) {
  if (typeof text !== 'string') return null;
  const m = text.match(/```json\s*([\s\S]*?)```/i) || text.match(/(\{[\s\S]*\})/);
  const body = m ? m[1] : text;
  try { const o = JSON.parse(body); return (o && typeof o === 'object') ? o : null; }
  catch { return null; }
}

/**
 * Оркестратор: построить промпт → llmCall (инъектируется; в проде callAnthropicAPI) →
 * распарсить → МЕХАНИЧЕСКИ провалидировать трассу (слоты) + заземлить кандидатов (ОВ-Д2) →
 * воздержание 5.2 при низкой уверенности. Сам выбор — у модели; всё вокруг — механика.
 */
export async function runRouter({ prompt, plan = null, graph, catalog = null, llmCall, confidenceThreshold = 0.5 }) {
  let trace;
  try { trace = await llmCall({ buildPrompt: () => buildRouterPrompt({ prompt, plan, graph, catalog }) }); }
  catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
  if (typeof trace === 'string') trace = parseRouterResponse(trace);
  if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null };
  const v = validateTrace(trace);
  if (!v.ok) return { ok: false, reason: `невалидная трасса: пустые слоты [${v.missingSlots.join(', ')}]`, trace };
  const g = groundTrace(trace, graph);
  if (!g.grounded) return { ok: false, reason: `выдуманные узлы (нет заземления): [${g.invented.join(', ')}]`, trace };
  if (typeof trace.confidence === 'number' && trace.confidence < confidenceThreshold)
    return { ok: true, abstain: true, reason: 'низкая уверенность — воздержание (5.2): переспросить/эскалация', trace };
  return { ok: true, abstain: false, trace };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" commit -am "feat(m3-d): buildRouterPrompt + parseRouterResponse + runRouter orchestrator (llmCall injected)"`

---

## Task 4: Дерево указателя шага (волны D6/OQ1) — стендовый модуль

**Files:** Create `tools/step-pointer.mjs`, `tools/step-pointer.test.mjs`

- [ ] **Step 1: Падающий тест** — `tools/step-pointer.test.mjs`

```js
import { describe, it, expect } from 'vitest';
import { createPointer, advance, enterSubPlan, exitSubPlan, currentPath, isDone } from './step-pointer.mjs';

describe('step-pointer (волны: линейный → дерево указателей, D6/OQ1)', () => {
  it('линейное продвижение даёт путь [n]', () => {
    let p = createPointer({ length: 3 });
    expect(currentPath(p)).toEqual([0]);
    p = advance(p); expect(currentPath(p)).toEqual([1]);
  });
  it('вход в под-план углубляет путь, выход возвращает', () => {
    let p = createPointer({ length: 2 });
    p = enterSubPlan(p, { length: 2 });           // шаг 0 раскрылся в под-план
    expect(currentPath(p)).toEqual([0, 0]);
    p = advance(p); expect(currentPath(p)).toEqual([0, 1]);
    p = exitSubPlan(p);                            // под-план исчерпан → назад к родителю
    p = advance(p); expect(currentPath(p)).toEqual([1]);
  });
  it('isDone когда корень исчерпан', () => {
    let p = createPointer({ length: 1 });
    expect(isDone(p)).toBe(false);
    p = advance(p);
    expect(isDone(p)).toBe(true);
  });
  it('линейный режим обратно совместим (один уровень = число indexAtRoot)', () => {
    let p = createPointer({ length: 5 });
    p = advance(advance(p));
    expect(p.indexAtRoot).toBe(2);
  });
});
```

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

- [ ] **Step 3: Реализация** — `tools/step-pointer.mjs`

```js
#!/usr/bin/env node
/**
 * step-pointer — указатель шага плана как ДЕРЕВО (волны D6/OQ1). Заменяет линейный
 * счётчик Машины 2, когда шаг раскрывается в под-план. СТЕНДОВЫЙ модуль: построен и
 * оттестирован, но в живой enforce-supreme-gate.main() НЕ врезается в этой сессии
 * (см. журнал вопросов — отдельный шаг). Линейный режим обратно совместим.
 */

/** Указатель = стек уровней [{index, length}]; верхний — текущий под-план. */
export function createPointer({ length }) {
  return { stack: [{ index: 0, length }] };
}

/** Текущий путь — индексы по уровням стека. */
export function currentPath(p) { return p.stack.map((l) => l.index); }

/** Индекс на корневом уровне (обратная совместимость с линейным счётчиком). */
function rootIndex(p) { return p.stack[0].index; }

/** Продвинуть указатель на текущем (верхнем) уровне. */
export function advance(p) {
  const stack = p.stack.map((l) => ({ ...l }));
  stack[stack.length - 1].index += 1;
  const np = { stack };
  np.indexAtRoot = rootIndex(np);
  return np;
}

/** Текущий шаг раскрылся в под-план длины length → углубляемся. */
export function enterSubPlan(p, { length }) {
  const stack = p.stack.map((l) => ({ ...l }));
  stack.push({ index: 0, length });
  const np = { stack };
  np.indexAtRoot = rootIndex(np);
  return np;
}

/** Под-план исчерпан → возвращаемся к родителю (на его текущем шаге). */
export function exitSubPlan(p) {
  if (p.stack.length <= 1) return p;
  const stack = p.stack.slice(0, -1).map((l) => ({ ...l }));
  const np = { stack };
  np.indexAtRoot = rootIndex(np);
  return np;
}

/** Готово, когда корневой уровень исчерпан. */
export function isDone(p) {
  const root = p.stack[0];
  return p.stack.length === 1 && root.index >= root.length;
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" add tools/step-pointer.mjs tools/step-pointer.test.mjs` + commit `"feat(m3-d): step-pointer tree (waves D6/OQ1) — standalone, not yet wired"`

---

## Task 5: Инварианты 3-D + регрессия

**Files:** Create `tools/m3d-router-invariants.test.mjs`

- [ ] **Step 1: Инвариант-тест** — собирает движок поверх РЕАЛЬНОГО графа узлов (loadRegistry) + проверяет, что выдуманный узел отклоняется, реальный проходит.

```js
import { describe, it, expect } from 'vitest';
import { loadRegistry, clearCache } from './registry-load.mjs';
import { buildNodeGraph } from './node-graph.mjs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runRouter, buildRouterPrompt, detectHighRisk } from './router-engine.mjs';

const here = dirname(fileURLToPath(import.meta.url));
const registryPath = join(here, '..', 'docs', 'registry', 'nodes.yaml');

describe('Машина 3-D — движок на реальном графе узлов', () => {
  it('выбор реального узла проходит, выдуманный отклоняется (на реальном графе)', async () => {
    clearCache();
    const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
    const real = await runRouter({ prompt: 'нужна архитектурная диаграмма', graph,
      llmCall: async () => ({ candidates: ['mermaid'], chosen: 'mermaid', why_chosen: 'диаграмма', twins_considered: 'нет', confidence: 0.9 }) });
    expect(real.ok).toBe(true);
    const fake = await runRouter({ prompt: 'x', graph,
      llmCall: async () => ({ candidates: ['totally-invented'], chosen: 'totally-invented', why_chosen: 'y', twins_considered: 'z', confidence: 0.9 }) });
    expect(fake.ok).toBe(false);
  });
  it('промпт несёт каталог реальных узлов', () => {
    clearCache();
    const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
    const { system } = buildRouterPrompt({ prompt: 'x', graph });
    expect(system).toMatch(/mermaid|adr-kit|writing-plans/);
  });
  it('детерминированный риск на прод-выкате не зависит от мнения модели', () => {
    expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
  });
});
```

- [ ] **Step 2: GREEN** (m3d invariants).
- [ ] **Step 3: Полная регрессия** — `npx vitest run --root ... --config ...` без фильтра → всё зелёное.
- [ ] **Step 4: Commit** — `git -C "<worktree>" add tools/m3d-router-invariants.test.mjs docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md` + commit `"test(m3-d): router-engine invariants on real graph + plan"`

---

## Self-Review (против канона §2/§3 3-D + §6)
- **Выбор по смыслу рассуждением (вызов мокается)** → runRouter(llmCall), buildRouterPrompt полный граф+каталог в system (6.2 кэш). ✅
- **Вшитый брейнсторм (2–3 варианта→спор→выбор) + L-ядро** → в system-инструкции. ✅
- **Видимая трасса 5.1 + механический валидатор (пустой слот=флаг, §6.2 A, отдельный код)** → validateTrace. ✅
- **Заземление (ОВ-Д2/§6.3 C, выдумка→отклонение)** → groundTrace (реюз 3-B resolveNode). ✅
- **Воздержание 5.2** → runRouter abstain при низкой уверенности. ✅
- **Риск-фильтр детерминированный (6.1), LLM-оценка только совет** → detectHighRisk (универсальные глаголы/прод/многошаг/инъецированный sensitive, портативно). ✅
- **Деньги 6.1 цена-разделитель** → cheaperOf (качество > цена). 6.2 пропуск уровня закрытым списком → validateLevelSkip. ✅
- **Волны D6/OQ1** → step-pointer (стендово, не врезано). ✅
- **K4-поправка + live-wiring + волны-в-живой-main** → ОТЛОЖЕНО (журнал вопросов; только после Машины 4 / шаг владельца). ✅
- **Нюх 5.3 / интервьюер 4.4 / самопроверка 5.4** — вшиты в system-инструкции брейнсторма (рассуждение модели), мех. часть — валидатор трассы; отдельные мех.-детекторы нюха — мягкий край (LLM), не строим жёстко. **[ДОПУЩЕНИЕ в журнал.]**
