# Машина 3 / под-план 3-E — Очередь одобрений + ручка разведки — план реализации

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

**Goal:** механизм обучения роутера, где **наполнение фонда примеров (3.4) и долгой памяти (3.5) — ТОЛЬКО по явному «да» владельца, ИНАЧЕ НИКАК** (hard-rule: без авто-обучения, без тихого накопления). Очередь-файл сильных кандидатов → раздел «На одобрение» (рендер для /brain-retro) → сигнал «ждут: N» (для STATUS.md) → одобрение пачкой. Плюс ручка «% разведки» (0 по умолчанию) + счётчик использования узлов + проба = ВОПРОС владельцу с обоснованием/риском (никогда авто).

**Architecture:** чистый `tools/router-learning-queue.mjs` (очередь предложений + пачка-одобрение + рендер + сигнал; fs инъектируется) + `tools/router-exploration.mjs` (ручка разведки #3, риск-гард через инъекцию). **Несущий инвариант:** `enqueue` НИКОГДА не ставит `approved`; только `applyApprovalBatch` с явным id в `approve` переводит в фонд. **Доставка в живой STATUS.md-генератор и brain-retro SKILL.md — follow-up** (owner-facing surface, не врезаем тихо ночью; рендер/сигнал готовы как функции).

**Tech Stack:** Node.js ESM, vitest tools-only. Реюз `router-engine` detectHighRisk (риск-гард разведки).

---

## ⚠️ Контекст исполнения (как 3-A..3-D)
- 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-E (что НЕ здесь — журнал вопросов)
- **Доставка** (врезка `statusSignal` в `tools/status-md-generator.mjs` + раздела «На одобрение» в `.claude/skills/brain-retro/SKILL.md`) — follow-up owner-facing, не делаем тихо. Рендер/сигнал — готовые функции.
- **Фактическое НАПОЛНЕНИЕ** очереди реальными кандидатами (оценщик предлагает) → только по «да» владельца + после Машины 4 (оценщик расширен). 3-E строит МЕХАНИЗМ, не наполняет.

---

## Структура файлов
**Создаём:**
- `tools/router-learning-queue.mjs` — `makeQueueEntry`, `enqueue`, `pendingCount`, `applyApprovalBatch`, `renderApprovalSection`, `statusSignal`, `loadQueue`/`saveQueue`.
- `tools/router-exploration.mjs` — `DEFAULT_EXPLORATION`, `tallyUsage`, `proposeProbe`.
- Тесты: `tools/router-learning-queue.test.mjs`, `tools/router-exploration.test.mjs`, `tools/m3e-learning-invariants.test.mjs`.

**Порядок:** очередь+пачка-одобрение+hard-rule (Task 1) → рендер+сигнал+персист (Task 2) → ручка разведки (Task 3) → инварианты hard-rule + регрессия (Task 4).

---

## Task 1: Очередь предложений + пачка-одобрение (hard-rule: без авто-наполнения)

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

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

```js
import { describe, it, expect } from 'vitest';
import { makeQueueEntry, enqueue, pendingCount, applyApprovalBatch } from './router-learning-queue.mjs';

const cand = (id, kind = 'example') => ({ id, kind, summary: `s-${id}`, why_proposed: `w-${id}` });

describe('enqueue (hard-rule: предложение, НИКОГДА не одобрено само)', () => {
  it('новый кандидат → status pending, не approved', () => {
    const q = enqueue([], cand('a'));
    expect(q).toHaveLength(1);
    expect(q[0].status).toBe('pending');
  });
  it('makeQueueEntry всегда pending (нет авто-одобрения)', () => {
    expect(makeQueueEntry(cand('x')).status).toBe('pending');
  });
  it('pendingCount считает только ожидающие', () => {
    const q = enqueue(enqueue([], cand('a')), cand('b'));
    expect(pendingCount(q)).toBe(2);
  });
});

describe('applyApprovalBatch (одобрение пачкой — ТОЛЬКО по явному решению владельца)', () => {
  const base = enqueue(enqueue(enqueue([], cand('1')), cand('2')), cand('3'));
  it('approve → в фонд; reject → помечен (не предлагать повторно); defer → остаётся pending', () => {
    const r = applyApprovalBatch(base, { approve: ['1'], reject: ['2'], defer: ['3'] });
    expect(r.fund.map((e) => e.id)).toEqual(['1']);
    expect(r.queue.find((e) => e.id === '2').status).toBe('rejected');
    expect(r.queue.find((e) => e.id === '3').status).toBe('pending');
    expect(pendingCount(r.queue)).toBe(1); // только '3'
  });
  it('пустое решение → ничего не входит в фонд (без «да» НИКАК)', () => {
    const r = applyApprovalBatch(base, {});
    expect(r.fund).toEqual([]);
    expect(pendingCount(r.queue)).toBe(3);
  });
  it('rejected повторно не «pending» (не предлагается снова)', () => {
    const once = applyApprovalBatch(base, { reject: ['1'] });
    expect(once.queue.find((e) => e.id === '1').status).toBe('rejected');
    expect(pendingCount(once.queue)).toBe(2);
  });
});
```

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

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

```js
#!/usr/bin/env node
/**
 * router-learning-queue — очередь обучения роутера (3.4 примеры / 3.5 долгая память).
 * НЕСУЩИЙ HARD-RULE (решение владельца 2026-06-03): наполнение фонда ТОЛЬКО по явному
 * «да» владельца, ИНАЧЕ НИКАК. Система только ПРЕДЛАГАЕТ (enqueue → pending); в фонд
 * переводит ТОЛЬКО applyApprovalBatch с явным id в approve. Без авто-обучения.
 */

/** Запись очереди — ВСЕГДА pending (нет авто-одобрения). */
export function makeQueueEntry(candidate) {
  return { id: candidate.id, kind: candidate.kind, summary: candidate.summary || '', why_proposed: candidate.why_proposed || '', status: 'pending' };
}

/** Добавить предложение (pending). Никогда не одобряет. */
export function enqueue(queue, candidate) {
  return [...(queue || []), makeQueueEntry(candidate)];
}

/** Сколько ждёт одобрения. */
export function pendingCount(queue) {
  return (queue || []).filter((e) => e.status === 'pending').length;
}

/**
 * Одобрение пачкой — ЕДИНСТВЕННЫЙ путь в фонд. approve → 'approved' + в fund;
 * reject → 'rejected' (помечен, не предлагать повторно); defer → остаётся 'pending'.
 * Без явного решения id остаётся pending. Возвращает {queue, fund, rejected}.
 */
export function applyApprovalBatch(queue, { approve = [], reject = [], defer = [] } = {}) {
  const aSet = new Set(approve), rSet = new Set(reject), dSet = new Set(defer);
  const fund = [], rejected = [];
  const newQueue = (queue || []).map((e) => {
    if (aSet.has(e.id)) { const ne = { ...e, status: 'approved' }; fund.push(ne); return ne; }
    if (rSet.has(e.id)) { const ne = { ...e, status: 'rejected' }; rejected.push(ne); return ne; }
    if (dSet.has(e.id)) return { ...e, status: 'pending' };
    return e;
  });
  return { queue: newQueue, fund, rejected };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" add tools/router-learning-queue.mjs tools/router-learning-queue.test.mjs` + commit `"feat(m3-e): learning queue — propose-only + owner-batch approval (hard-rule no auto-fill)"`

---

## Task 2: Рендер «На одобрение» (/brain-retro) + сигнал «ждут: N» (STATUS) + персист

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

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

```js
import { renderApprovalSection, statusSignal, saveQueue, loadQueue } from './router-learning-queue.mjs';

describe('renderApprovalSection (/brain-retro «На одобрение»)', () => {
  it('перечисляет только pending с id/summary/why', () => {
    const q = enqueue(applyApprovalBatch(enqueue(enqueue([], cand('1')), cand('2')), { approve: ['1'] }).queue, cand('3'));
    const md = renderApprovalSection(q);
    expect(md).toMatch(/На одобрение/);
    expect(md).toMatch(/2/); // pending '2'
    expect(md).toMatch(/3/); // pending '3'
    expect(md).not.toMatch(/s-1/); // '1' approved — не в списке
  });
  it('пустая очередь → пометка «нет кандидатов»', () => {
    expect(renderApprovalSection([])).toMatch(/нет кандидатов|пусто/i);
  });
});

describe('statusSignal (STATUS.md «ждут: N»)', () => {
  it('строка с числом ожидающих', () => {
    const q = enqueue(enqueue([], cand('a')), cand('b'));
    expect(statusSignal(q)).toMatch(/ждут.*2/);
  });
});

describe('персист очереди (fs инъектируется)', () => {
  function memFs() {
    const m = new Map();
    return { m, readFileSync: (p) => { if (!m.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return m.get(String(p)); }, writeFileSync: (p, d) => m.set(String(p), String(d)) };
  }
  it('save→load round-trips', () => {
    const fs = memFs();
    const q = enqueue([], cand('a'));
    saveQueue({ queue: q, path: '/q.json', fsImpl: fs });
    expect(loadQueue({ path: '/q.json', fsImpl: fs })).toEqual(q);
  });
  it('нет файла → []', () => {
    expect(loadQueue({ path: '/none', fsImpl: memFs() })).toEqual([]);
  });
});
```

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

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

```js
import fsDefault from 'node:fs';

/** Рендер раздела «На одобрение» для /brain-retro (только pending). */
export function renderApprovalSection(queue) {
  const pend = (queue || []).filter((e) => e.status === 'pending');
  if (pend.length === 0) return '## На одобрение\n\n_нет кандидатов_';
  const lines = pend.map((e) => `- [${e.id}] (${e.kind}) ${e.summary} — почему: ${e.why_proposed}`);
  return `## На одобрение (${pend.length})\n\n${lines.join('\n')}\n\nОтвет: «одобряю 1,3 / отклоняю 2 / отложить 4».`;
}

/** Сигнал для STATUS.md: «ждут одобрения: N». */
export function statusSignal(queue) {
  return `ждут одобрения: ${pendingCount(queue)}`;
}

/** Персист очереди (fs инъектируется). */
export function saveQueue({ queue, path, fsImpl = fsDefault }) {
  fsImpl.writeFileSync(path, JSON.stringify(queue));
}
export function loadQueue({ path, fsImpl = fsDefault }) {
  try { return JSON.parse(fsImpl.readFileSync(path, 'utf8')); }
  catch (e) { if (e && e.code === 'ENOENT') return []; throw e; }
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" commit -am "feat(m3-e): approval render + STATUS signal + queue persistence"`

---

## Task 3: Ручка разведки «% разведки» (#3) — проба = ВОПРОС владельцу

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

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

```js
import { describe, it, expect } from 'vitest';
import { DEFAULT_EXPLORATION, tallyUsage, proposeProbe } from './router-exploration.mjs';

describe('DEFAULT_EXPLORATION (по умолчанию ВЫКЛючена)', () => {
  it('rate = 0', () => { expect(DEFAULT_EXPLORATION.rate).toBe(0); });
});

describe('tallyUsage (счётчик использования узлов)', () => {
  it('инкремент по узлу', () => {
    const c = tallyUsage(tallyUsage({}, '#a'), '#a');
    expect(c['#a']).toBe(2);
  });
});

describe('proposeProbe (#3 — проба = ВОПРОС владельцу, не авто)', () => {
  const cands = [{ id: '#a' }, { id: '#b' }, { id: '#c' }];
  const usage = { '#a': 100, '#b': 100, '#c': 1 }; // #c почти не пробован
  it('rate=0 → нет пробы (анти-самоослепление выключено по умолчанию)', () => {
    expect(proposeProbe({ rate: 0, usageCounts: usage, candidates: cands })).toBe(null);
  });
  it('rate>0 → предлагает наименее использованный, requiresOwnerApproval', () => {
    const p = proposeProbe({ rate: 0.2, usageCounts: usage, candidates: cands });
    expect(p.probe).toBe('#c');
    expect(p.requiresOwnerApproval).toBe(true);
    expect(typeof p.justification).toBe('string');
    expect(typeof p.risk).toBe('string');
  });
  it('риск-гард: высокорисковый узел не предлагается к пробе', () => {
    const riskOf = (id) => id === '#c'; // #c высокорисковый
    const p = proposeProbe({ rate: 0.2, usageCounts: usage, candidates: cands, riskOf });
    expect(p === null || p.probe !== '#c').toBe(true);
  });
});
```

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

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

```js
#!/usr/bin/env node
/**
 * router-exploration — ручка «% разведки» (#3 крит-разбор). По умолчанию ВЫКЛючена
 * (rate=0) — анти-самоослепление петли обучения предусмотрено СЛОТОМ, но включается
 * позже. Проба нового узла = ВОПРОС владельцу с обоснованием+риском (никогда авто;
 * наполняется только по «да», как 3.4/3.5). Высокорисковые узлы к пробе не предлагаются.
 */
export const DEFAULT_EXPLORATION = Object.freeze({ rate: 0 });

/** Счётчик использования узлов (для выбора наименее пробованного). */
export function tallyUsage(counts, nodeId) {
  const c = { ...(counts || {}) };
  c[nodeId] = (c[nodeId] || 0) + 1;
  return c;
}

/**
 * Предложить пробу наименее использованного узла. rate<=0 → null (выкл). Иначе среди
 * кандидатов, НЕ высокорисковых (riskOf инъектируется), берём с минимальным счётчиком →
 * вопрос владельцу (requiresOwnerApproval=true) с обоснованием+риском. Не применяет сам.
 */
export function proposeProbe({ rate = 0, usageCounts = {}, candidates = [], riskOf = () => false } = {}) {
  if (!(rate > 0)) return null;
  const eligible = candidates.filter((c) => !riskOf(c.id));
  if (eligible.length === 0) return null;
  const least = eligible.reduce((best, c) =>
    (usageCounts[c.id] || 0) < (usageCounts[best.id] || 0) ? c : best, eligible[0]);
  return {
    probe: least.id,
    requiresOwnerApproval: true,
    justification: `узел ${least.id} почти не пробован (использований: ${usageCounts[least.id] || 0}) — разведка может найти лучший инструмент`,
    risk: 'непроверенный узел может сработать хуже привычного; включается только по твоему «да»',
  };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git -C "<worktree>" add tools/router-exploration.mjs tools/router-exploration.test.mjs` + commit `"feat(m3-e): exploration knob (#3) — probe = owner question, off by default"`

---

## Task 4: Инварианты hard-rule + регрессия

**Files:** Create `tools/m3e-learning-invariants.test.mjs`

- [ ] **Step 1: Инвариант-тест** — `tools/m3e-learning-invariants.test.mjs`

```js
import { describe, it, expect } from 'vitest';
import { enqueue, applyApprovalBatch, pendingCount } from './router-learning-queue.mjs';
import { proposeProbe } from './router-exploration.mjs';

const cand = (id) => ({ id, kind: 'lesson', summary: `s-${id}`, why_proposed: `w-${id}` });

describe('Машина 3-E — несущие инварианты hard-rule', () => {
  it('БЕЗ явного «да» владельца НИЧЕГО не входит в фонд (no auto-fill)', () => {
    let q = [];
    for (let i = 0; i < 10; i++) q = enqueue(q, cand(`c${i}`)); // оценщик «предложил» 10
    const r = applyApprovalBatch(q, {}); // владелец молчит
    expect(r.fund).toEqual([]);          // фонд пуст
    expect(pendingCount(r.queue)).toBe(10);
  });
  it('разведка по умолчанию НЕ инициирует пробу (rate=0)', () => {
    expect(proposeProbe({ rate: 0, candidates: [{ id: '#x' }] })).toBe(null);
  });
  it('проба, даже включённая, требует одобрения владельца (никогда авто)', () => {
    const p = proposeProbe({ rate: 0.5, candidates: [{ id: '#x' }], usageCounts: {} });
    expect(p.requiresOwnerApproval).toBe(true);
  });
});
```

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

---

## Self-Review (против канона §2 3-E + §6)
- **Очередь сильных кандидатов (3.4 примеры / 3.5 память)** → router-learning-queue (makeQueueEntry/enqueue). ✅
- **Наполнение ТОЛЬКО по явному «да», ИНАЧЕ НИКАК (hard-rule, без авто-обучения)** → enqueue всегда pending; в фонд только applyApprovalBatch с явным approve; пустое решение → фонд пуст. Инвариант-тест. ✅
- **Раздел «На одобрение» в /brain-retro** → renderApprovalSection (рендер готов; врезка в SKILL.md — follow-up). ✅
- **Сигнал «ждут: N» в STATUS.md** → statusSignal (готов; врезка в status-md-generator — follow-up). ✅
- **Одобрение пачкой** → applyApprovalBatch (approve/reject/defer). ✅
- **Ручка «% разведки» (0 по умолчанию) + счётчик узлов + проба = вопрос владельцу с обоснованием/риском (#3)** → router-exploration (rate 0 default, tallyUsage, proposeProbe requiresOwnerApproval). ✅
- **Риск-гард разведки** → proposeProbe riskOf (высокорисковый не предлагается). ✅
- **Доставка в живую инфру (STATUS/brain-retro)** — follow-up, не тихо ночью (журнал). ✅
