Машина 3-B «Граф узлов из реестра» собрана (TDD): node-graph.mjs поверх loadRegistry — buildNodeGraph/resolveNode (ОВ-Д2 заземление) + twinsOf (subcategory) / hintLinksOf (chains) / conflictsOf (явные) + checkGraphFreshness (3.6). 20 новых тестов, регрессия tools-only 2139 GREEN.
19 KiB
Машина 3 / под-план 3-B — Граф узлов из реестра — план реализации
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps — checkbox. NB сборки: код строит контроллер по TDD (субагент не проходит TDD-гейт).
Goal: дать роутеру (3-D) и судье (М4) СТАБИЛЬНУЮ карту инструментов из РЕЕСТРА (docs/registry/nodes.yaml, НЕ graphify): узлы = скилы/MCP/агенты, рёбра-подсказки = близнецы (общий subcategory) / связи (со-членство в цепочке) / конфликты (явные) + механическое заземление скила в реальный узел (ОВ-Д2) + чувство свежести (3.6).
Architecture: чистый tools/node-graph.mjs поверх существующего tools/registry-load.mjs (loadRegistry → {nodes, chains, indexById}; js-yaml+ajv уже в проекте). Граф НЕ парсит YAML сам — потребляет registry-объект. Рёбра — ПОДСКАЗКИ (не рецепты, ОВ-Д1): близнецы/связи/конфликты выводятся механически из существующих полей (subcategory / chains / attributes.conflicts_with). Резолв скила в узел — детерминированный (id/slug/name/suffix; выдумка → null). Без LLM («умный не баран»).
Tech Stack: Node.js ESM, vitest tools-only. Реюз loadRegistry (registry-load.mjs).
⚠️ Контекст исполнения (как 3-A)
- Git только
git -C "<worktree>". Тесты:npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>через Bash,dangerouslyDisableSandbox=true, безcd(router-gate режет цепочки). - Тест-файлы — в
<worktree>\tools\, писать ЦЕЛИКОМ через Write. - TDD-гейт: Read этого плана прямыми слэшами + правка теста + Bash-RED, потом прод.
Границы 3-B (что НЕ здесь)
- Машина охвата A/B/C/D (граф нужд needs↔produces из КОНТРАКТОВ 3-A) → 3-C. NB: 3-B — граф УЗЛОВ (инструменты+связи-подсказки), 3-C — граф НУЖД (декомпозиция по контрактам). Разные графы.
- Выбор скила рассуждением, look-ahead, L-ядро → 3-D.
- [ДОПУЩЕНИЕ] Явных рёбер «близнецы/альтернативы/конфликты» в схеме реестра НЕТ. Выводим: близнецы = общий
subcategory; связи-подсказки = со-членство вchains; конфликты = опциональноеattributes.conflicts_with(массив ref'ов; пусто, если не задано). Фаззи-парсинг free-textboundariesНЕ делаем (ненадёжно). Явные конфликт-рёбра — наполнение позже (вне 3-B). - [ДОПУЩЕНИЕ] Члены цепочек вида
superpowers:brainstormingмогут не резолвиться в отдельный узел (если суб-скилы superpowers не отдельные узлы реестра) → в связях-подсказках такие члены пропускаются. Граф корректен для зарегистрированных узлов.
Структура файлов
Создаём:
tools/node-graph.mjs—buildNodeGraph(registry),resolveNode(graph, ref)(ОВ-Д2),twinsOf,hintLinksOf,conflictsOf,checkGraphFreshness(3.6).- Тесты:
tools/node-graph.test.mjs(чистый, на fake-registry),tools/m3b-node-graph-invariants.test.mjs(на РЕАЛЬНОМ реестре через loadRegistry).
Порядок: buildNodeGraph+resolveNode (Task 1) → twins/hints/conflicts (Task 2) → freshness 3.6 (Task 3) → инварианты на реальном реестре + регрессия (Task 4).
Task 1: buildNodeGraph + resolveNode (ОВ-Д2 заземление)
Files: Create tools/node-graph.mjs, tools/node-graph.test.mjs
- Step 1: Падающий тест —
tools/node-graph.test.mjs
import { describe, it, expect } from 'vitest';
import { buildNodeGraph, resolveNode } from './node-graph.mjs';
// fake-registry в форме loadRegistry (nodes + chains)
const REG = {
nodes: [
{ id: '#19', name: 'Superpowers', slug: 'superpowers', subcategory: null, status: 'active' },
{ id: '#36', name: 'adr-kit', slug: 'adr-kit', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#37', name: 'mermaid-skill', slug: 'mermaid', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#38', name: 'architecture-patterns', slug: 'architecture-patterns', subcategory: 'architecture-tooling', status: 'active' },
{ id: '#17', name: 'pg_partman', slug: 'pg-partman', subcategory: null, status: 'dormant' },
],
chains: {
L4: { name: 'diagram', sequence: ['#36', '#37'] },
},
};
describe('buildNodeGraph', () => {
it('builds id/slug/name indexes + node count', () => {
const g = buildNodeGraph(REG);
expect(g.nodes).toHaveLength(5);
expect(g.byId.get('#36').name).toBe('adr-kit');
expect(g.bySlug.get('mermaid').id).toBe('#37');
});
});
describe('resolveNode (ОВ-Д2 — механическое заземление)', () => {
const g = buildNodeGraph(REG);
it('resolves by exact id', () => { expect(resolveNode(g, '#36').slug).toBe('adr-kit'); });
it('resolves by exact slug', () => { expect(resolveNode(g, 'mermaid').id).toBe('#37'); });
it('resolves by exact name', () => { expect(resolveNode(g, 'adr-kit').id).toBe('#36'); });
it('resolves by suffix after colon (skill-ref)', () => {
expect(resolveNode(g, 'superpowers:architecture-patterns').id).toBe('#38');
});
it('invented skill → null (выдумка отклоняется)', () => {
expect(resolveNode(g, 'elasticsearch-mcp')).toBe(null);
expect(resolveNode(g, '#999')).toBe(null);
});
it('empty/garbage → null', () => {
expect(resolveNode(g, '')).toBe(null);
expect(resolveNode(g, null)).toBe(null);
});
});
-
Step 2: RED —
... skill ... node-graph→ FAIL (import). -
Step 3: Реализация —
tools/node-graph.mjs
#!/usr/bin/env node
/**
* node-graph — стабильный граф УЗЛОВ из реестра (3.1/3.2/3.3 + 3.6). Узлы =
* скилы/MCP/агенты из docs/registry/nodes.yaml; рёбра-ПОДСКАЗКИ (не рецепты):
* близнецы (общий subcategory) / связи (со-членство в chains) / конфликты
* (явные attributes.conflicts_with). Резолв скила в узел детерминированный
* (ОВ-Д2: выдумка → null). Без LLM. Потребляет registry от loadRegistry.
*/
/** Построить граф из registry ({nodes, chains}). Индексы id/slug/name + субкатегории + цепочки. */
export function buildNodeGraph(registry) {
const nodes = (registry && registry.nodes) || [];
const chains = (registry && registry.chains) || {};
const byId = new Map(), bySlug = new Map(), byName = new Map();
const subcategoryIndex = new Map();
for (const n of nodes) {
if (n.id) byId.set(String(n.id), n);
if (n.slug) bySlug.set(String(n.slug).toLowerCase(), n);
if (n.name) byName.set(String(n.name).toLowerCase(), n);
if (n.subcategory) {
if (!subcategoryIndex.has(n.subcategory)) subcategoryIndex.set(n.subcategory, []);
subcategoryIndex.get(n.subcategory).push(n);
}
}
return { nodes, chains, byId, bySlug, byName, subcategoryIndex };
}
/**
* ОВ-Д2 — механический резолв ссылки в реальный узел: id (#NN) → slug → name →
* суффикс после ':' (skill-ref как superpowers:brainstorming) как slug/name.
* Не нашли → null (выдумка отклоняется, не выдаём догадку за факт).
*/
export function resolveNode(graph, ref) {
if (!graph || typeof ref !== 'string') return null;
const r = ref.trim();
if (!r) return null;
if (graph.byId.has(r)) return graph.byId.get(r);
const low = r.toLowerCase();
if (graph.bySlug.has(low)) return graph.bySlug.get(low);
if (graph.byName.has(low)) return graph.byName.get(low);
if (r.includes(':')) {
const suf = low.split(':').pop();
if (graph.bySlug.has(suf)) return graph.bySlug.get(suf);
if (graph.byName.has(suf)) return graph.byName.get(suf);
}
return null;
}
- Step 4: GREEN.
- Step 5: Commit —
git -C "<worktree>" add tools/node-graph.mjs tools/node-graph.test.mjs+ commit"feat(m3-b): node-graph buildNodeGraph + resolveNode (ОВ-Д2 grounding)"
Task 2: Рёбра-подсказки — twinsOf / hintLinksOf / conflictsOf
Files: Modify tools/node-graph.mjs, tools/node-graph.test.mjs
- Step 1: Падающие тесты (добавить)
import { twinsOf, hintLinksOf, conflictsOf } from './node-graph.mjs';
describe('twinsOf (близнецы = общий subcategory, активные)', () => {
const g = buildNodeGraph(REG);
it('возвращает соседей по subcategory без себя', () => {
const t = twinsOf(g, '#36').map((n) => n.id).sort();
expect(t).toEqual(['#37', '#38']);
});
it('узел без subcategory → нет близнецов', () => {
expect(twinsOf(g, '#19')).toEqual([]);
});
it('несуществующий ref → []', () => { expect(twinsOf(g, 'nope')).toEqual([]); });
});
describe('hintLinksOf (связи = со-членство в цепочке)', () => {
const g = buildNodeGraph(REG);
it('соседи по chains, без себя', () => {
expect(hintLinksOf(g, '#36').map((n) => n.id)).toEqual(['#37']);
expect(hintLinksOf(g, '#37').map((n) => n.id)).toEqual(['#36']);
});
it('узел вне цепочек → []', () => { expect(hintLinksOf(g, '#19')).toEqual([]); });
});
describe('conflictsOf (явные attributes.conflicts_with)', () => {
it('резолвит явные конфликт-рёбра', () => {
const reg = { nodes: [
{ id: '#a', slug: 'a', status: 'active', attributes: { conflicts_with: ['#b'] } },
{ id: '#b', slug: 'b', status: 'active' },
], chains: {} };
const g = buildNodeGraph(reg);
expect(conflictsOf(g, '#a').map((n) => n.id)).toEqual(['#b']);
});
it('нет поля → []', () => {
const g = buildNodeGraph(REG);
expect(conflictsOf(g, '#36')).toEqual([]);
});
});
- Step 2: RED.
- Step 3: Реализация (дописать)
/** Близнецы — активные узлы той же subcategory (без себя). Роутер ОБЯЗАН сравнить (1.1). */
export function twinsOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self || !self.subcategory) return [];
return (graph.subcategoryIndex.get(self.subcategory) || [])
.filter((n) => n !== self && n.status === 'active');
}
/** Связи-подсказки — узлы, со-встречающиеся с этим в любой цепочке (без себя), дедуп. */
export function hintLinksOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self) return [];
const seen = new Set(), out = [];
for (const chain of Object.values(graph.chains || {})) {
const members = (chain.sequence || []).map((s) => resolveNode(graph, s)).filter(Boolean);
if (!members.includes(self)) continue;
for (const m of members) {
if (m === self || seen.has(m.id)) continue;
seen.add(m.id); out.push(m);
}
}
return out;
}
/** Конфликты — явные attributes.conflicts_with (массив ref'ов), резолвятся в узлы. Free-text не парсим. */
export function conflictsOf(graph, ref) {
const self = resolveNode(graph, ref);
if (!self) return [];
const refs = (self.attributes && self.attributes.conflicts_with) || [];
return refs.map((r) => resolveNode(graph, r)).filter(Boolean);
}
- Step 4: GREEN.
- Step 5: Commit —
git -C "<worktree>" commit -am "feat(m3-b): hint edges — twinsOf/hintLinksOf/conflictsOf"
Task 3: Чувство свежести (3.6)
Files: Modify tools/node-graph.mjs, tools/node-graph.test.mjs
- Step 1: Падающие тесты (добавить)
import { checkGraphFreshness } from './node-graph.mjs';
describe('checkGraphFreshness (3.6)', () => {
it('реестр не новее сборки → fresh', () => {
expect(checkGraphFreshness({ registryMtimeMs: 100, builtAtMs: 200 })).toMatchObject({ fresh: true, stale: false });
});
it('реестр новее сборки → stale + причина', () => {
const r = checkGraphFreshness({ registryMtimeMs: 300, builtAtMs: 200 });
expect(r.stale).toBe(true); expect(r.fresh).toBe(false); expect(r.reason).toMatch(/устар|реестр новее|stale/i);
});
it('нет данных о времени сборки → stale (не доверяем уверенно)', () => {
expect(checkGraphFreshness({ registryMtimeMs: 100, builtAtMs: null }).stale).toBe(true);
});
});
- Step 2: RED.
- Step 3: Реализация (дописать)
/**
* 3.6 — чувство свежести: реестр новее, чем дата сборки графа/каталога → данные
* могли устареть (роутер предупреждает + пересобирает, не работает уверенно по старью).
* mtimes инъектируются (чистая функция). Нет builtAt → считаем устаревшим.
*/
export function checkGraphFreshness({ registryMtimeMs, builtAtMs }) {
if (typeof builtAtMs !== 'number') return { fresh: false, stale: true, reason: 'нет даты сборки графа — пересобрать' };
if (typeof registryMtimeMs === 'number' && registryMtimeMs > builtAtMs)
return { fresh: false, stale: true, reason: 'реестр новее графа — данные могли устареть, пересобрать' };
return { fresh: true, stale: false, reason: 'граф актуален' };
}
- Step 4: GREEN.
- Step 5: Commit —
git -C "<worktree>" commit -am "feat(m3-b): checkGraphFreshness (3.6 freshness sense)"
Task 4: Инварианты на РЕАЛЬНОМ реестре + регрессия
Files: Create tools/m3b-node-graph-invariants.test.mjs
- Step 1: Инвариант-тест —
tools/m3b-node-graph-invariants.test.mjs
import { describe, it, expect } from 'vitest';
import { statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadRegistry, clearCache } from './registry-load.mjs';
import { buildNodeGraph, resolveNode, twinsOf, checkGraphFreshness } from './node-graph.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const registryPath = join(here, '..', 'docs', 'registry', 'nodes.yaml');
describe('Машина 3-B — граф на реальном реестре', () => {
it('граф строится из nodes.yaml и резолвит известные узлы', () => {
clearCache();
const reg = loadRegistry({ registryPath, useCache: false });
const g = buildNodeGraph(reg);
expect(g.nodes.length).toBeGreaterThan(50);
expect(resolveNode(g, '#36')).not.toBe(null); // adr-kit by id
expect(resolveNode(g, 'mermaid')).not.toBe(null); // by slug
expect(resolveNode(g, 'totally-made-up-skill')).toBe(null); // выдумка
});
it('близнецы architecture-tooling включают друг друга', () => {
clearCache();
const reg = loadRegistry({ registryPath, useCache: false });
const g = buildNodeGraph(reg);
const adr = resolveNode(g, 'adr-kit');
if (adr && adr.subcategory) {
const twinSlugs = twinsOf(g, adr.id).map((n) => n.slug);
expect(twinSlugs.length).toBeGreaterThanOrEqual(1);
}
});
it('freshness против реальной mtime реестра работает', () => {
const mtime = statSync(registryPath).mtimeMs;
expect(checkGraphFreshness({ registryMtimeMs: mtime, builtAtMs: mtime + 1000 }).fresh).toBe(true);
expect(checkGraphFreshness({ registryMtimeMs: mtime, builtAtMs: mtime - 1000 }).stale).toBe(true);
});
});
NB:
loadRegistryкэширует — используемuseCache: false+clearCache()(как в registry-load.test.mjs), чтобы не словить чужой кэш. ЕслиresolveNode(g, 'mermaid')вернёт null — slug в реестре другой; поправить ref по факту (записать в журнал).
- Step 2: GREEN —
... m3b-node-graph-invariants. - Step 3: Полная регрессия —
npx vitest run --root ... --config ...(без фильтра) → всё зелёное. - Step 4: Commit —
git -C "<worktree>" add tools/m3b-node-graph-invariants.test.mjs docs/superpowers/plans/2026-06-04-router-mentor-3b-node-graph.md+ commit"test(m3-b): node-graph invariants on real registry + plan"
Self-Review (против канона §2 3-B)
- Стабильная карта из РЕЕСТРА, не graphify →
buildNodeGraph(registry)поверхloadRegistry. ✅ - Узлы = скилы/MCP/агенты, рёбра = близнецы/связи/конфликты (подсказки) → twinsOf (subcategory) / hintLinksOf (chains) / conflictsOf (explicit). ✅ (близнецы/связи выведены механически; конфликты — явные, free-text не парсим — [ДОПУЩЕНИЕ] в журнал).
- Заземление ОВ-Д2: скил механически резолвится в реальный узел, выдумка → отклонение →
resolveNode(id/slug/name/suffix; null на выдумку). ✅ - 3.6 свежесть: дата каталога против даты правки реестра →
checkGraphFreshness(mtimes инъектируются; реальная mtime в инвариантах). ✅ - Без LLM, портативно → чистый модуль, имена не зашиты (всё из реестра). ✅
- Граница с 3-C (граф НУЖД из контрактов) и 3-D (выбор рассуждением) — отмечена. ✅