# Машина 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 (выбор рассуждением) — отмечена. ✅
