# Sub-plan B — слоистый project-граф + staleness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Чистый модуль `tools/project-graph.mjs`, который читает артефакты graphify-out (`graph.json` + staleness-файлы) и производит слоистую подачу project-графа наставнику (§5.3 спеки): **layer-0** = прунённая карта ~10-15 «районов» (директорий-ось ПЕРВИЧНА, 1009 communities — ВТОРИЧНЫЙ cohesion-сигнал, Гибрид) + **layer-1** = детали района + inline staleness-поле `{stale, commits_behind, uncommitted}` (✅O16).

**Architecture:** Чистое ядро по образцу `tools/context-verity.mjs` / `tools/plan-lock.mjs::refResolves` `[plan-lock.mjs:226]` — никакого прямого I/O, доступ к файлам/git инъектируется (`readFileImpl` / `gitImpl`) для детерминированных тестов. Модуль standalone и тестируем изолированно на синтетических мини-графах; wiring в `buildRouterPrompt` (`groundTrace` `graph`-секция, CD-R6-G) ОТЛОЖЕН в sub-plan C, где наставник реально зовётся. B экспортирует `buildGraphSection` готовым к подключению.

**Tech Stack:** Node ESM (`.mjs`), vitest (tools-config). Источник истины спеки: `docs/superpowers/specs/2026-06-10-router-mentor-live-rebuild-design.md` §5.3 (слоистая подача + CD-R6-G) / §5.4 (staleness ✅O16) / ДР-3 / ДР-P1.

**Сверенные факты graphify-out (junction `.claude/worktrees/graphify-spike/graphify-out/`, граф построен из commit `81cbd8c1`):**
- `graph.json` — networkx node-link формат `{directed, multigraph, graph, nodes[], links[]}` `[graph.json:1-5]`.
  - `nodes[]` = `{ label, file_type, source_file, source_location, community, norm_label, local_id, id }` `[graph.json:6-19]`. `file_type` ∈ `document|rationale|concept|...` `[graph.json:8,68,653]`. `source_file` — реальный путь. **`community` НЕРЕДКО `null`** (на doc-узлах) `[graph.json:15]`.
  - `links[]` = `{ relation, confidence, confidence_score, source_file, source_location, weight, source, target }` `[graph.json:90851-90861]`. `source`/`target` = node `id` (напр. `::::artifact_audit_2026_05_09`). Степень узла = число вхождений его `id` в `links`.
- `.graphify_labels.json` — 1009 записей, **ВСЕ безымянные `"N": "Community N"`** → именование районов = работа этого модуля, не готовое в graphify.
- Staleness-источники: файл `needs_update` («N doc files changed. Last: <ISO>») `[needs_update:1-2]` + `needs_update.log` + граф-commit `81cbd8c1` `[GRAPH_REPORT.md:13]` + git-uncommitted.
- Build-артефакты ТРЕКАЮТСЯ графом → реальный прунинг-target `[needs_update.log:380-429]` (`app/public/build/assets/*.css`).

**Scope (граница):**
- **В B:** `tools/project-graph.mjs` (чистый) + `tools/project-graph.test.mjs`. Функции: `parseGraph`, `nodeDistrict` (+ `DISTRICT_RULES`), `pruneJunk` (+ `PRUNE_PREFIXES`), `nodeDegrees`, `buildDistrictMap`, `districtDetail`, `readStaleness`, `buildGraphSection`.
- **НЕ в B (→ sub-plan C):** wiring `buildGraphSection` в `tools/router-engine.mjs::buildRouterPrompt` (`catalog`≠`graph` разделение, переписать «(100%)» `[router-engine.mjs:114]`) — это шов наставника, C. B экспортирует секцию готовой.
- **НЕ в B:** auto-refresh графа (ДР-2 enhancement) — каноничен только СИГНАЛ staleness (§5.4 S1); refresh поверх — отдельно.

**Квирк среды (ОБЯЗАТЕЛЬНО для шагов «Run»):** vitest запускается ТОЛЬКО из `app/`:
```
cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs <фильтр> --reporter dot
```
cwd между Bash-вызовами ненадёжен — каждый Run-шаг префиксить `cd app && ...` в одной команде.

**NB git:** в этой среде git зажат стеной у Claude — `git commit`-шаги выполняет ВЛАДЕЛЕЦ/исполнитель вне Claude-стены (или субагент с git-доступом per Pravila §15.1). Команды коммитов даны как есть.

**NB I/O-чистота:** ни одна функция модуля не делает прямой `fs`/`git`-вызов. Чтение инъектируется. Это позволяет тестировать на синтетических графах и НЕ зависеть от реального (1.5MB) `graph.json` в юнит-тестах.

---

### Task 1: `parseGraph` — node-link JSON → `{nodes, links}` (защитно)

**Files:**
- Create: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

- [ ] **Step 1: Write the failing test**

```js
// tools/project-graph.test.mjs
import { describe, it, expect } from 'vitest';
import { parseGraph } from './project-graph.mjs';

describe('parseGraph', () => {
  it('парсит node-link JSON в {nodes, links}', () => {
    const text = JSON.stringify({
      directed: false, multigraph: false, graph: {},
      nodes: [{ id: 'a', label: 'A', file_type: 'code', source_file: 'tools/x.mjs', community: 3 }],
      links: [{ source: 'a', target: 'b', relation: 'references' }],
    });
    const g = parseGraph(text);
    expect(g.nodes).toHaveLength(1);
    expect(g.links).toHaveLength(1);
    expect(g.nodes[0].id).toBe('a');
  });
  it('битый JSON → {nodes:[], links:[]} (не крашит)', () => {
    expect(parseGraph('{не json')).toEqual({ nodes: [], links: [] });
  });
  it('отсутствующие массивы → пустые', () => {
    expect(parseGraph('{}')).toEqual({ nodes: [], links: [] });
  });
  it('не-строка → {nodes:[], links:[]}', () => {
    expect(parseGraph(null)).toEqual({ nodes: [], links: [] });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `Cannot find module './project-graph.mjs'` / `parseGraph is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// tools/project-graph.mjs
#!/usr/bin/env node
/**
 * project-graph (§5.3/§5.4) — слоистая подача project-графа наставнику.
 * Читает graphify-out graph.json (networkx node-link) + staleness-сигнал.
 * Чистое ядро (I/O инъектируется), образец — context-verity / plan-lock.refResolves.
 * Гибрид (владелец 2026-06-10): директорий-ось = ПЕРВИЧНАЯ партиция районов;
 * 1009 communities = ВТОРИЧНЫЙ cohesion-сигнал (god-node hints внутри района).
 * Карта районов — ДЁШЕВАЯ «карта метро» (ДР-3 слой-0), НЕ содержимое файлов.
 */

/** Защитный парс node-link JSON → {nodes, links}. Любой сбой → пустые массивы. */
export function parseGraph(text) {
  if (typeof text !== 'string') return { nodes: [], links: [] };
  let obj;
  try { obj = JSON.parse(text); } catch { return { nodes: [], links: [] }; }
  if (!obj || typeof obj !== 'object') return { nodes: [], links: [] };
  return {
    nodes: Array.isArray(obj.nodes) ? obj.nodes : [],
    links: Array.isArray(obj.links) ? obj.links : [],
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 4 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): parseGraph node-link защитный (sub-plan B Task 1)"
```

---

### Task 2: `nodeDistrict` + `DISTRICT_RULES` — директорий-ось (первичная партиция)

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**ДР-P1 «свернуть в ~10-15 человеко-именованных районов».** Первичная ось — верхний каталог `source_file` (Гибрид). `DISTRICT_RULES` — упорядоченный список `{prefix, name}`, первый совпавший префикс выигрывает; fallback `'other'`. Правила покрывают реальные префиксы графа (см. Step 0 ниже — enumeration сверяет, что `'other'` мал).

- [ ] **Step 0: Сверить реальные префиксы графа (НЕ выдумывать районы)**

Run (read-only, разрешён стеной):
```
cd app && node -e "const fs=require('fs');const p='../.claude/worktrees/graphify-spike/graphify-out/graph.json';const g=JSON.parse(fs.readFileSync(p,'utf8'));const c={};for(const n of g.nodes){const sf=n.source_file||'';const top=sf.split('/').slice(0,2).join('/');c[top]=(c[top]||0)+1;}console.log(Object.entries(c).sort((a,b)=>b[1]-a[1]).slice(0,40));"
```
Expected: печатает топ-40 двухсегментных префиксов `source_file` с количествами. **Скорректировать `DISTRICT_RULES` в Step 3 так, чтобы `'other'`-bucket был < ~10% узлов.** Это заземляет имена районов в реальных данных, а не в фантазии (правило владельца «targets ПРОВЕРИТЬ против graphify-out»). Зафиксировать вывод в комментарии коммита.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { nodeDistrict, DISTRICT_RULES } from './project-graph.mjs';

describe('nodeDistrict', () => {
  it('docs/superpowers → docs-superpowers', () => {
    expect(nodeDistrict('docs/superpowers/specs/x.md')).toBe('docs-superpowers');
  });
  it('docs/observer → docs-observer', () => {
    expect(nodeDistrict('docs/observer/STATUS.md')).toBe('docs-observer');
  });
  it('app/resources/js → app-frontend', () => {
    expect(nodeDistrict('app/resources/js/views/DealsView.vue')).toBe('app-frontend');
  });
  it('app/app (php backend) → app-backend', () => {
    expect(nodeDistrict('app/app/Services/LeadRouter.php')).toBe('app-backend');
  });
  it('tools/ → tools', () => {
    expect(nodeDistrict('tools/router-engine.mjs')).toBe('tools');
  });
  it('неизвестный/пустой источник → other', () => {
    expect(nodeDistrict('some/random/thing.txt')).toBe('other');
    expect(nodeDistrict('')).toBe('other');
    expect(nodeDistrict(null)).toBe('other');
  });
  it('DISTRICT_RULES заморожен и упорядочен (specific перед generic)', () => {
    expect(Object.isFrozen(DISTRICT_RULES)).toBe(true);
    const docsSuperIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/superpowers/');
    const docsIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/');
    expect(docsSuperIdx).toBeGreaterThanOrEqual(0);
    expect(docsSuperIdx).toBeLessThan(docsIdx); // specific раньше generic
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `nodeDistrict is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
/**
 * Правила район<-source_file (ПЕРВИЧНАЯ ось, Гибрид). Порядок ВАЖЕН: specific
 * префикс ДО generic (docs/superpowers/ до docs/). Префиксы сверены против
 * реального graphify-out (Task 2 Step 0). Скорректировать под Step 0-вывод,
 * сохраняя «specific раньше generic» и инвариант теста.
 */
export const DISTRICT_RULES = Object.freeze([
  { prefix: 'docs/superpowers/', name: 'docs-superpowers' },
  { prefix: 'docs/observer/', name: 'docs-observer' },
  { prefix: 'docs/adr/', name: 'docs-adr' },
  { prefix: 'docs/registry/', name: 'docs-registry' },
  { prefix: 'docs/', name: 'docs' },
  { prefix: 'app/resources/js/', name: 'app-frontend' },
  { prefix: 'app/tests/', name: 'app-tests' },
  { prefix: 'app/database/', name: 'app-db' },
  { prefix: 'app/', name: 'app-backend' },
  { prefix: 'tools/', name: 'tools' },
  { prefix: 'db/', name: 'db' },
  { prefix: '.claude/skills/', name: 'claude-skills' },
  { prefix: '.claude/agents/', name: 'claude-agents' },
  { prefix: '.claude/', name: 'claude-config' },
]);

/** Район узла по source_file (первый совпавший префикс; fallback 'other'). */
export function nodeDistrict(sourceFile, rules = DISTRICT_RULES) {
  const sf = typeof sourceFile === 'string' ? sourceFile : '';
  if (!sf) return 'other';
  for (const r of rules) {
    if (sf.startsWith(r.prefix)) return r.name;
  }
  return 'other';
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 11 passed (4 + 7).

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): nodeDistrict директорий-ось (Гибрид первичная, sub-plan B Task 2)"
```

---

### Task 3: `pruneJunk` + `PRUNE_PREFIXES` — отсечь мусор

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**ДР-P1 прунинг.** `PRUNE_PREFIXES` — денилист source_file-префиксов. `app/public/build/` подтверждён фактом `[needs_update.log:380-429]`. Остальные кандидаты (node_modules / vendor / .git / bin) включены, но СВЕРЕНЫ enumeration-шагом (Step 0) — добавлять в денилист только реально присутствующие в графе (не выдумка).

- [ ] **Step 0: Сверить присутствие junk-префиксов (НЕ выдумывать)**

Run (read-only):
```
cd app && node -e "const fs=require('fs');const p='../.claude/worktrees/graphify-spike/graphify-out/graph.json';const g=JSON.parse(fs.readFileSync(p,'utf8'));const pats=['app/public/build/','node_modules/','app/node_modules/','app/vendor/','vendor/','bin/','.git/'];const c={};for(const n of g.nodes){const sf=n.source_file||'';for(const x of pats)if(sf.startsWith(x))c[x]=(c[x]||0)+1;}console.log(c);"
```
Expected: печатает, какие junk-префиксы РЕАЛЬНО есть в графе и сколько узлов. **`PRUNE_PREFIXES` в Step 3 = только подтверждённые (>0) префиксы.** Зафиксировать в комментарии коммита.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { pruneJunk, PRUNE_PREFIXES } from './project-graph.mjs';

describe('pruneJunk', () => {
  const nodes = [
    { id: '1', source_file: 'docs/x.md' },
    { id: '2', source_file: 'app/public/build/assets/app-x.css' },
    { id: '3', source_file: 'tools/router-engine.mjs' },
    { id: '4', source_file: null },
  ];
  it('узлы с junk-префиксом отсечены, остальные сохранены', () => {
    const r = pruneJunk(nodes, ['app/public/build/']);
    expect(r.kept.map((n) => n.id)).toEqual(['1', '3', '4']);
    expect(r.prunedCount).toBe(1);
  });
  it('PRUNE_PREFIXES заморожен и включает подтверждённый build', () => {
    expect(Object.isFrozen(PRUNE_PREFIXES)).toBe(true);
    expect(PRUNE_PREFIXES).toContain('app/public/build/');
  });
  it('не-массив → {kept:[], prunedCount:0}', () => {
    expect(pruneJunk(null, PRUNE_PREFIXES)).toEqual({ kept: [], prunedCount: 0 });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `pruneJunk is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
/**
 * Junk-префиксы source_file (build/зависимости/осиротевшее). Сверены против
 * реального graphify-out (Task 3 Step 0) — содержит ТОЛЬКО реально присутствующие.
 * 'app/public/build/' подтверждён [needs_update.log build-CSS]. Скорректировать
 * под Step 0-вывод (добавить подтверждённые node_modules/vendor/.git если >0).
 */
export const PRUNE_PREFIXES = Object.freeze([
  'app/public/build/',
]);

/** Отсечь узлы с junk-префиксом source_file. @returns {{kept, prunedCount}} */
export function pruneJunk(nodes, prunePrefixes = PRUNE_PREFIXES) {
  const list = Array.isArray(nodes) ? nodes : [];
  const kept = [];
  let prunedCount = 0;
  for (const n of list) {
    const sf = (n && typeof n.source_file === 'string') ? n.source_file : '';
    if (sf && prunePrefixes.some((p) => sf.startsWith(p))) { prunedCount++; continue; }
    kept.push(n);
  }
  return { kept, prunedCount };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 14 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): pruneJunk денилист (build подтверждён, sub-plan B Task 3)"
```

---

### Task 4: `nodeDegrees` + `buildDistrictMap` — LAYER-0 (карта районов)

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**§5.3 слой-0 «карта метро» — ВСЕГДА, дёшево.** Каждый район: `{ district, nodeCount, topNodes, communities }`. `topNodes` = до N узлов района с наибольшей степенью (из `links`) — это god-nodes района. `communities` = отсортированный список НЕ-null `community`-id, встречающихся в районе — ВТОРИЧНЫЙ cohesion-сигнал (Гибрид). Степень считается ПОСЛЕ прунинга по сохранённым узлам.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { nodeDegrees, buildDistrictMap } from './project-graph.mjs';

describe('nodeDegrees', () => {
  it('считает степень по вхождениям id в source/target', () => {
    const links = [
      { source: 'a', target: 'b' },
      { source: 'a', target: 'c' },
      { source: 'b', target: 'c' },
    ];
    const deg = nodeDegrees(links);
    expect(deg.get('a')).toBe(2);
    expect(deg.get('b')).toBe(2);
    expect(deg.get('c')).toBe(2);
  });
  it('пустые/битые links → пустая карта', () => {
    expect(nodeDegrees(null).size).toBe(0);
  });
});

describe('buildDistrictMap (layer-0)', () => {
  const nodes = [
    { id: 'a', label: 'A', source_file: 'tools/router-engine.mjs', community: 5 },
    { id: 'b', label: 'B', source_file: 'tools/judge-engine.mjs', community: 5 },
    { id: 'c', label: 'C', source_file: 'tools/escape-grant.mjs', community: 7 },
    { id: 'd', label: 'D', source_file: 'docs/observer/STATUS.md', community: null },
    { id: 'j', label: 'J', source_file: 'app/public/build/assets/x.css', community: 1 },
  ];
  const links = [
    { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'b', target: 'c' },
  ];
  it('район tools имеет 3 узла, топ по степени, communities вторично', () => {
    const map = buildDistrictMap(nodes, links, { topNodesPerDistrict: 2 });
    const tools = map.find((d) => d.district === 'tools');
    expect(tools.nodeCount).toBe(3);
    expect(tools.topNodes[0].id).toBe('a'); // степень 2 (наибольшая, lexically первый при равенстве)
    expect(tools.topNodes).toHaveLength(2); // cap
    expect(tools.communities).toEqual([5, 7]); // вторичный сигнал, non-null, отсортирован
  });
  it('junk-узлы (build) НЕ попадают в карту', () => {
    const map = buildDistrictMap(nodes, links, {});
    expect(map.find((d) => d.district === 'app-frontend' || d.district === 'app-backend')).toBeUndefined();
    expect(map.every((d) => d.district !== 'other' || d.nodeCount > 0)).toBe(true);
  });
  it('район docs-observer: 1 узел, communities пуст (все null)', () => {
    const map = buildDistrictMap(nodes, links, {});
    const obs = map.find((d) => d.district === 'docs-observer');
    expect(obs.nodeCount).toBe(1);
    expect(obs.communities).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `nodeDegrees is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
/** Степень каждого узла = число вхождений его id в source/target всех links. */
export function nodeDegrees(links) {
  const deg = new Map();
  if (!Array.isArray(links)) return deg;
  for (const l of links) {
    if (!l || typeof l !== 'object') continue;
    for (const end of [l.source, l.target]) {
      if (typeof end === 'string' && end) deg.set(end, (deg.get(end) || 0) + 1);
    }
  }
  return deg;
}

/**
 * LAYER-0: карта районов (§5.3). Прунит junk → группирует по району (директорий-ось)
 * → на район: nodeCount + topNodes (по степени, god-nodes района) + communities
 * (вторичный cohesion-сигнал, Гибрид). Чистая; links/rules/префиксы инъектируемы.
 * @returns {Array<{district, nodeCount, topNodes:[{id,label,degree}], communities:number[]}>}
 */
export function buildDistrictMap(nodes, links, opts = {}) {
  const rules = opts.rules || DISTRICT_RULES;
  const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES;
  const topN = Number.isInteger(opts.topNodesPerDistrict) ? opts.topNodesPerDistrict : 5;
  const { kept } = pruneJunk(nodes, prunePrefixes);
  const deg = nodeDegrees(links);
  const byDistrict = new Map();
  for (const n of kept) {
    if (!n || typeof n !== 'object') continue;
    const d = nodeDistrict(n.source_file, rules);
    if (!byDistrict.has(d)) byDistrict.set(d, []);
    byDistrict.get(d).push(n);
  }
  const out = [];
  for (const [district, ns] of byDistrict) {
    const ranked = ns
      .map((n) => ({ id: n.id, label: n.label, degree: deg.get(n.id) || 0 }))
      .sort((x, y) => (y.degree - x.degree) || String(x.id).localeCompare(String(y.id)))
      .slice(0, topN);
    const comms = Array.from(new Set(
      ns.map((n) => n.community).filter((c) => typeof c === 'number'),
    )).sort((a, b) => a - b);
    out.push({ district, nodeCount: ns.length, topNodes: ranked, communities: comms });
  }
  return out.sort((a, b) => b.nodeCount - a.nodeCount || a.district.localeCompare(b.district));
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 20 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): buildDistrictMap layer-0 + nodeDegrees (Гибрид, sub-plan B Task 4)"
```

---

### Task 5: `districtDetail` — LAYER-1 (детали района)

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**§5.3 слой-1 «зона задачи».** Узлы запрошенного района, сгруппированные по community (вторично). Роутер дозирует глубину — модуль отдаёт ПОЛНЫЙ список района; усечение/выбор — забота C/наставника.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { districtDetail } from './project-graph.mjs';

describe('districtDetail (layer-1)', () => {
  const nodes = [
    { id: 'a', label: 'A', file_type: 'code', source_file: 'tools/router-engine.mjs', community: 5 },
    { id: 'b', label: 'B', file_type: 'code', source_file: 'tools/judge-engine.mjs', community: 5 },
    { id: 'c', label: 'C', file_type: 'code', source_file: 'tools/escape-grant.mjs', community: 7 },
    { id: 'd', label: 'D', file_type: 'document', source_file: 'docs/observer/STATUS.md', community: null },
  ];
  it('возвращает узлы района + группировку по community', () => {
    const r = districtDetail(nodes, 'tools', {});
    expect(r.district).toBe('tools');
    expect(r.nodes.map((n) => n.id).sort()).toEqual(['a', 'b', 'c']);
    expect(r.byCommunity['5'].sort()).toEqual(['a', 'b']);
    expect(r.byCommunity['7']).toEqual(['c']);
  });
  it('пустой район → пустые', () => {
    const r = districtDetail(nodes, 'nope', {});
    expect(r.nodes).toEqual([]);
    expect(r.byCommunity).toEqual({});
  });
  it('узлы с community:null группируются под "none"', () => {
    const r = districtDetail(nodes, 'docs-observer', {});
    expect(r.byCommunity.none).toEqual(['d']);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `districtDetail is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
/**
 * LAYER-1: детали района (§5.3). Узлы района + группировка по community (вторично,
 * Гибрид). community:null → ключ 'none'. Прунинг применяется (junk не в детали).
 * @returns {{district, nodes:[{id,label,file_type,community}], byCommunity:Record<string,string[]>}}
 */
export function districtDetail(nodes, district, opts = {}) {
  const rules = opts.rules || DISTRICT_RULES;
  const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES;
  const { kept } = pruneJunk(nodes, prunePrefixes);
  const inDistrict = kept.filter(
    (n) => n && typeof n === 'object' && nodeDistrict(n.source_file, rules) === district,
  );
  const byCommunity = {};
  for (const n of inDistrict) {
    const key = typeof n.community === 'number' ? String(n.community) : 'none';
    (byCommunity[key] ||= []).push(n.id);
  }
  return {
    district,
    nodes: inDistrict.map((n) => ({
      id: n.id, label: n.label, file_type: n.file_type, community: n.community ?? null,
    })),
    byCommunity,
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 23 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): districtDetail layer-1 + community-группировка (sub-plan B Task 5)"
```

---

### Task 6: `readStaleness` — inline staleness-поле (✅O16)

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**§5.4 ✅O16 каноничный СИГНАЛ.** Чистое ядро `readStaleness(inputs)` принимает примитивы (НЕ читает диск/git) → `{stale, commits_behind, uncommitted}`. `stale` = присутствует `needs_update` ИЛИ граф-commit ≠ HEAD. Сбор сырых входов (чтение `needs_update`, граф-commit из GRAPH_REPORT, `git rev-list --count`, `git status`) — тонкая обёртка `gatherStalenessInputs({readFileImpl, gitImpl})`, тестируется через моки.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { readStaleness, gatherStalenessInputs } from './project-graph.mjs';

describe('readStaleness (✅O16 pure)', () => {
  it('needs_update присутствует → stale true', () => {
    const r = readStaleness({ needsUpdatePresent: true, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 });
    expect(r).toEqual({ stale: true, commits_behind: 0, uncommitted: 0 });
  });
  it('commit рассинхрон → stale true даже без needs_update', () => {
    const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'bbb', commitsBehind: 12, uncommittedCount: 3 });
    expect(r).toEqual({ stale: true, commits_behind: 12, uncommitted: 3 });
  });
  it('всё чисто → stale false', () => {
    const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 });
    expect(r).toEqual({ stale: false, commits_behind: 0, uncommitted: 0 });
  });
  it('кривые числа → 0 (не NaN)', () => {
    const r = readStaleness({ needsUpdatePresent: true, commitsBehind: 'x', uncommittedCount: undefined });
    expect(r.commits_behind).toBe(0);
    expect(r.uncommitted).toBe(0);
  });
});

describe('gatherStalenessInputs (обёртка через моки)', () => {
  it('собирает входы из инъектированных fs/git', () => {
    const readFileImpl = (p) => p.endsWith('needs_update') ? 'Run /graphify --update (4 changed)' : 'Built from commit: `81cbd8c1`';
    const existsImpl = () => true;
    const gitImpl = (args) => args.includes('rev-list') ? '7' : ' M a.mjs\n?? b.mjs';
    const inp = gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit: 'deadbeef' });
    expect(inp.needsUpdatePresent).toBe(true);
    expect(inp.graphCommit).toBe('81cbd8c1');
    expect(inp.commitsBehind).toBe(7);
    expect(inp.uncommittedCount).toBe(2);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `readStaleness is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
const toInt = (v) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0);

/**
 * ✅O16 каноничное inline staleness-поле. Чистое: примитивы → {stale, commits_behind,
 * uncommitted}. stale = needs_update присутствует ИЛИ граф-commit ≠ HEAD. Сигнал
 * ВСЕГДА (не «триггер ИЛИ сигнал», §5.4 S1) — refresh поверх это enhancement (вне B).
 */
export function readStaleness(inputs = {}) {
  const present = inputs.needsUpdatePresent === true;
  const mismatch = !!inputs.graphCommit && !!inputs.headCommit && inputs.graphCommit !== inputs.headCommit;
  return {
    stale: present || mismatch,
    commits_behind: toInt(inputs.commitsBehind),
    uncommitted: toInt(inputs.uncommittedCount),
  };
}

/**
 * Тонкая обёртка: собирает сырые входы для readStaleness из инъектированных fs/git.
 * needsUpdatePath — graphify-out/needs_update; graphReportPath — GRAPH_REPORT.md
 * (строка «Built from commit: `<sha>`»). gitImpl(argsArray) → stdout-строка.
 */
export function gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit,
  needsUpdatePath = '.claude/worktrees/graphify-spike/graphify-out/needs_update',
  graphReportPath = '.claude/worktrees/graphify-spike/graphify-out/GRAPH_REPORT.md' }) {
  const needsUpdatePresent = (() => {
    try { return !!existsImpl(needsUpdatePath); } catch { return false; }
  })();
  let graphCommit = '';
  try {
    const rep = String(readFileImpl(graphReportPath) || '');
    const m = rep.match(/Built from commit:\s*`?([0-9a-f]{7,40})`?/i);
    if (m) graphCommit = m[1];
  } catch { graphCommit = ''; }
  let commitsBehind = 0;
  try {
    if (graphCommit && headCommit) commitsBehind = toInt(gitImpl(['rev-list', '--count', `${graphCommit}..${headCommit}`]));
  } catch { commitsBehind = 0; }
  let uncommittedCount = 0;
  try {
    const st = String(gitImpl(['status', '--porcelain']) || '').trim();
    uncommittedCount = st ? st.split('\n').filter((l) => l.trim()).length : 0;
  } catch { uncommittedCount = 0; }
  return { needsUpdatePresent, graphCommit, headCommit, commitsBehind, uncommittedCount };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 29 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): readStaleness inline-поле O16 + gather-обёртка (sub-plan B Task 6)"
```

---

### Task 7: `buildGraphSection` — сборка граф-секции (CD-R6-G, готова к wiring в C)

**Files:**
- Modify: `tools/project-graph.mjs`
- Test: `tools/project-graph.test.mjs`

**§5.3 CD-R6-G: project-граф — ОТДЕЛЬНАЯ секция промпта, НЕ skill-каталог.** `buildGraphSection({districtMap, staleness})` собирает финальный объект секции (layer-0 карта + inline staleness-поле). Это то, что sub-plan C врежет в `buildRouterPrompt` как `graph`-вход (заземляемый против полного, `groundTrace` `[router-engine.mjs:99,148]`). B НЕ трогает router-engine — только производит секцию.

- [ ] **Step 1: Write the failing test**

```js
// добавить в project-graph.test.mjs
import { buildGraphSection } from './project-graph.mjs';

describe('buildGraphSection (CD-R6-G)', () => {
  const districtMap = [
    { district: 'tools', nodeCount: 3, topNodes: [{ id: 'a', label: 'A', degree: 2 }], communities: [5, 7] },
    { district: 'docs-observer', nodeCount: 1, topNodes: [], communities: [] },
  ];
  const staleness = { stale: true, commits_behind: 12, uncommitted: 3 };
  it('секция содержит kind, районы (layer-0) и inline staleness', () => {
    const s = buildGraphSection({ districtMap, staleness });
    expect(s.kind).toBe('project-graph');
    expect(s.layer0).toBe(districtMap);
    expect(s.staleness).toEqual(staleness);
    expect(s.districtCount).toBe(2);
  });
  it('пустая карта → districtCount 0, staleness сохранён', () => {
    const s = buildGraphSection({ districtMap: [], staleness });
    expect(s.districtCount).toBe(0);
    expect(s.staleness.stale).toBe(true);
  });
  it('отсутствующий staleness → безопасный дефолт stale:true (fail-safe сигнал)', () => {
    const s = buildGraphSection({ districtMap: [] });
    expect(s.staleness.stale).toBe(true); // нет сигнала → считаем сталым (§5.4 сигнал ВСЕГДА)
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: FAIL — `buildGraphSection is not a function`.

- [ ] **Step 3: Write minimal implementation**

```js
// добавить в project-graph.mjs
/**
 * CD-R6-G: собрать ОТДЕЛЬНУЮ project-граф секцию промпта (layer-0 карта районов +
 * inline staleness ✅O16). НЕ skill-каталог. Готова к wiring в buildRouterPrompt (C).
 * Отсутствующий staleness → fail-safe stale:true (сигнал ВСЕГДА, §5.4).
 */
export function buildGraphSection({ districtMap = [], staleness } = {}) {
  const layer0 = Array.isArray(districtMap) ? districtMap : [];
  const safeStaleness = (staleness && typeof staleness === 'object')
    ? { stale: !!staleness.stale, commits_behind: toInt(staleness.commits_behind), uncommitted: toInt(staleness.uncommitted) }
    : { stale: true, commits_behind: 0, uncommitted: 0 };
  return {
    kind: 'project-graph',
    districtCount: layer0.length,
    layer0,
    staleness: safeStaleness,
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/project-graph.test.mjs --reporter dot`
Expected: PASS — 32 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "feat(project-graph): buildGraphSection CD-R6-G готова к wiring C (sub-plan B Task 7)"
```

---

### Task 8: Регрессия tools-vitest GREEN + smoke на РЕАЛЬНОМ graph.json

**Files:** (нет правок — проверка)

- [ ] **Step 1: Прогнать ВЕСЬ tools-набор**

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs --reporter dot`
Expected: PASS — весь tools-набор зелёный, новый `project-graph.test.mjs` (32) включён, 0 регрессий в соседних машинах М1-М7 и sub-plan A `context-verity`. Зафиксировать число passed (baseline для sub-plan C).

- [ ] **Step 2: Smoke на реальном graph.json (read-only, без правок кода)**

Run:
```
cd app && node -e "const fs=require('fs');const G=require('../tools/project-graph.mjs');const p='../.claude/worktrees/graphify-spike/graphify-out/graph.json';const {nodes,links}=G.parseGraph(fs.readFileSync(p,'utf8'));const map=G.buildDistrictMap(nodes,links,{topNodesPerDistrict:3});console.log('districts:',map.length);for(const d of map)console.log(d.district,d.nodeCount,d.topNodes.map(n=>n.label).slice(0,2));const other=map.find(d=>d.district==='other');console.log('OTHER share:',other?(other.nodeCount/nodes.length*100).toFixed(1)+'%':'0%');"
```
Expected: печатает ~10-15 районов с реальными счётчиками + god-nodes; **`OTHER share` < ~10%** (иначе `DISTRICT_RULES` недопокрывает — вернуться к Task 2 Step 0 и расширить правила). Это финальная проверка «свёртка 1009 communities → ~10-15 районов» на ЖИВЫХ данных. НЕ коммитить (read-only smoke).

- [ ] **Step 3: Если RED (тесты ИЛИ OTHER>10%) — systematic-debugging**

При падении тестов или раздутом `other` — НЕ править наугад: `superpowers:systematic-debugging`, root cause, точечный фикс (расширить `DISTRICT_RULES` под реальные префиксы Task 2 Step 0 / поправить `PRUNE_PREFIXES`), повтор Step 1-2.

- [ ] **Step 4: Commit (если были фиксы Step 3; иначе пропустить)**

```bash
git add tools/project-graph.mjs tools/project-graph.test.mjs
git commit -m "fix(project-graph): регрессия tools-vitest GREEN + smoke районов (sub-plan B Task 8)"
```

---

## R6.3 amendments (ревью 5-скил цепочки 2026-06-10)

Поправки к задачам выше (исполнять ВМЕСТЕ; OLD→NEW):

**SE7 (Task 2/3 Step 0 ↔ тесты).** Дополнить Step 0 обоих: «Вывод Step 0 правит И `DISTRICT_RULES`/`PRUNE_PREFIXES`, И КЕЙСЫ тестов Step 1 — предзаписанные assert'ы ИЛЛЮСТРАТИВНЫ (под реальные top-N префиксы). Сохранять инвариант "specific перед generic" + OTHER<10% (Task 8)».

**SE8 (Task 2/3 Step 0 + Task 8 Step 2 — junction-fallback).** Нота: «Если junction `graphify-out/graph.json` отсутствует/нечитаем (СТАЛЫЙ, commit 81cbd8c1) — НЕ застревать: владелец регенерирует граф (`/graphify --update`) ЛИБО baseline-префиксы из плана + пометить smoke OTHER "не верифицирован на живых данных"».

**SE11 (Task 6 readStaleness/gatherStalenessInputs — behind-unknown).** При ошибке `git rev-list` (граф-commit вне истории) — `commits_behind` = `null` (НЕИЗВЕСТНО), НЕ `0`. Контракт: `readStaleness`→`{stale, commits_behind: number|null, uncommitted}`. Нота: «`stale:true` + `commits_behind:null` = "устарел, величина неизвестна" (честнее 0)». Обновить тесты Task 6 (`commits_behind` может быть null).

**V-3 (data-const PLACEHOLDER).** `DISTRICT_RULES`/`PRUNE_PREFIXES` пометить «PLACEHOLDER — заменить выводом Step 0» (не выдумка, заземляется enumeration'ом).

**V-6 / SE13 (cross-cutting).** Step 0 enumeration + Task 8 smoke + регрессия — «[НЕ-TDD: enumeration/smoke/регрессия]». «N passed» — иллюстративно; сверяй ЗЕЛЁНОСТЬ, не точное число.

**Self-review B — обновить:** +SE7/SE8/SE11 + V-3 placeholder-метки; type `readStaleness.commits_behind`→`number|null`.

---

## Self-Review (writing-plans)

**1. Spec coverage:**
- §5.3 слоистая подача (layer-0 карта районов + layer-1 детали) → Task 4/5. ✅
- §5.3 CD-R6-G project-граф ОТДЕЛЬНАЯ секция (не каталог), готова к wiring в `groundTrace` → Task 7 (wiring в `buildRouterPrompt` → sub-plan C, явно отложено). ✅
- §5.3 / ДР-P1 прунинг (targets сверены против graphify-out, не выдуманы) → Task 3 (Step 0 enumeration). ✅
- §5.3 / ДР-P1 свёртка 1009 communities → ~10-15 районов (Гибрид: директорий-ось первичная, communities вторичны) → Task 2/4 + Task 8 Step 2 smoke (OTHER<10%). ✅
- §5.4 / ✅O16 inline staleness-поле `{stale, commits_behind, uncommitted}`, сигнал ВСЕГДА → Task 6/7. ✅
- Регрессия → Task 8. ✅
- **Вне B (→ C):** wiring в `buildRouterPrompt`, переписать «(100%)» `[router-engine.mjs:114]`, журнал переговоров, A0-хук, mentor-вердикт. НЕ покрыты намеренно (sub-plan C). **Вне B:** auto-refresh графа (ДР-2 enhancement) — §5.4 канон = сигнал, не refresh.

**2. Placeholder scan:** реальный код в каждом code-шаге; команды Run с ожидаемым выводом; «выдумки» районов/junk закрыты enumeration-шагами (Task 2/3 Step 0) против реального graph.json; нет «TBD». ✅

**3. Type consistency:** `parseGraph`→`{nodes,links}`; `nodeDistrict`→string; `pruneJunk`→`{kept,prunedCount}`; `nodeDegrees`→`Map`; `buildDistrictMap`→`[{district,nodeCount,topNodes:[{id,label,degree}],communities:number[]}]`; `districtDetail`→`{district,nodes,byCommunity}`; `readStaleness`→`{stale,commits_behind,uncommitted}`; `gatherStalenessInputs`→inputs-объект для `readStaleness`; `buildGraphSection`→`{kind,districtCount,layer0,staleness}`. Имена согласованы между задачами. ✅

**Известное ограничение (честно):** районы Гибрида опираются на ПЕРВИЧНУЮ директорий-ось; 1009 алгоритмических communities используются лишь как ВТОРИЧНЫЙ cohesion-сигнал (`communities`-поле района + `byCommunity`-группировка), НЕ как несущая ось партиции. Это сознательный выбор владельца (2026-06-10) ради детерминизма и устойчивости к `community:null`. Если позже понадобится истинная свёртка communities по плотности рёбер — отдельный O-вопрос для будущего sub-plan, не дефект B.
