Машина 3-E «Очередь одобрений + ручка разведки» собрана (TDD): router-learning-queue.mjs (propose-only + owner batch approval + render/signal/persist; hard-rule «без да — никак») + router-exploration.mjs (ручка %разведки=0 default, проба=вопрос владельцу, риск-гард). 19 новых тестов. Финальная регрессия tools-only 2212 GREEN. МАШИНА 3 (Роутер-наставник) собрана целиком: 3-A контракты / 3-B граф узлов / 3-C машина охвата / 3-D движок роутера / 3-E очередь обучения. Доставка в живую инфру (STATUS/brain-retro), K4-поправка, live-wiring, перенос волн — follow-up после Машины 4 (журнал вопросов).
20 KiB
Машина 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
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
#!/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: Падающие тесты (добавить)
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: Реализация (дописать)
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
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
#!/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
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, не тихо ночью (журнал). ✅