#!/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-объект {nodes, chains} — * обычно от 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 (фикс-1: суб-скилы superpowers не отдельные узлы → ссылка * superpowers:writing-plans заземляется в зонтичный узел superpowers #19). * Суффикс пробуется ПЕРВЫМ — если сам суффикс является узлом, он и побеждает * (superpowers:architecture-patterns → #38). Не нашли → 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 parts = low.split(':'); const suf = parts[parts.length - 1]; if (graph.bySlug.has(suf)) return graph.bySlug.get(suf); if (graph.byName.has(suf)) return graph.byName.get(suf); const pre = parts[0]; if (graph.bySlug.has(pre)) return graph.bySlug.get(pre); if (graph.byName.has(pre)) return graph.byName.get(pre); } return null; } /** Близнецы — активные узлы той же 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); } /** * 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: 'граф актуален' }; }