62030e23a7
Машина 3-B «Граф узлов из реестра» собрана (TDD): node-graph.mjs поверх loadRegistry — buildNodeGraph/resolveNode (ОВ-Д2 заземление) + twinsOf (subcategory) / hintLinksOf (chains) / conflictsOf (явные) + checkGraphFreshness (3.6). 20 новых тестов, регрессия tools-only 2139 GREEN.
340 lines
19 KiB
Markdown
340 lines
19 KiB
Markdown
# Машина 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-text `boundaries` НЕ делаем (ненадёжно). Явные конфликт-рёбра — наполнение позже (вне 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`
|
|
|
|
```js
|
|
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`
|
|
|
|
```js
|
|
#!/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: Падающие тесты (добавить)**
|
|
|
|
```js
|
|
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: Реализация (дописать)**
|
|
|
|
```js
|
|
/** Близнецы — активные узлы той же 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: Падающие тесты (добавить)**
|
|
|
|
```js
|
|
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: Реализация (дописать)**
|
|
|
|
```js
|
|
/**
|
|
* 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`
|
|
|
|
```js
|
|
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 (выбор рассуждением) — отмечена. ✅
|