# Машина 3 — Граф зависимостей решений (#2) + дисциплина доменного навыка (#3) — TDD-план

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps `- [ ]`.
> **Этот репозиторий:** код строит САМ контроллер inline. `commit` — owner-gated. Новые тест-файлы через Write.

**Goal:** Достроить два последних механизма «ловли всплывающих нужд» дизайна §4: (#2) НОВЫЙ граф зависимостей РЕШЕНИЙ/секций артефакта (сменил решение → механически подсвечиваются зависимые секции на перепроверку — тупой наводчик внимания), (#3) дисциплина РЕАЛЬНОГО вызова доменного навыка (журнал-проверяемо, K2 — пересказ вместо вызова ловится).

**Architecture:** Два новых focused-модуля чистых функций в `tools/`: `decision-graph.mjs` (узлы=секции, рёбра=«зависит от §X», транзитивное `impactedBy` cycle-safe) и `domain-skill-discipline.mjs` (сверка рекомендованных доменных навыков с фактически вызванными). Оба $0, без LLM, инъекция фактов. `decision-graph` НЕ путать с графом НАВЫКОВ (`coverage-machine`) — это другой граф (решения, не скилы).

**Tech Stack:** Node.js ESM (`.mjs`), vitest tools-only.

**Источник:** spec §4 #2 (граф зависимостей решений — тупой наводчик; ловит только объявленные зависимости) + #3 (реальный вызов доменного навыка рано, K2). §5 честный потолок: оба сужают зазор на ОБЪЯВЛЕННОМ, не ловят по-настоящему новое (это контроллер+судья+владелец).

**Границы:** связка в живой поток (подсветка при реальной смене решения; реальный вызов доменного навыка в фазе артефакта) — live-wiring/дисциплина движка при активации; здесь — механические примитивы + тесты.

---

## File Structure

| Файл | Ответственность | Действие |
|---|---|---|
| `tools/decision-graph.mjs` | #2: `buildDecisionGraph`, `validateSections`, `dependentsOf`, `impactedBy`. | **Create** |
| `tools/decision-graph.test.mjs` | TDD. | **Create** |
| `tools/domain-skill-discipline.mjs` | #3: `uncalledDomainSkills`, `domainCallDiscipline`. | **Create** |
| `tools/domain-skill-discipline.test.mjs` | TDD. | **Create** |

---

## Task 1: `decision-graph.mjs` — построение графа + валидатор

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

- [ ] **Step 1: Тест**

```js
import { describe, it, expect } from 'vitest';
import { buildDecisionGraph, validateSections } from './decision-graph.mjs';

const SECTIONS = [
  { id: 'отступ', dependsOn: [] },
  { id: 'вёрстка', dependsOn: ['отступ'] },
  { id: 'мобильный', dependsOn: ['вёрстка', 'отступ'] },
];

describe('buildDecisionGraph (узлы=секции, рёбра=зависит-от)', () => {
  it('узлы = id секций', () => {
    expect(buildDecisionGraph(SECTIONS).nodes).toEqual(['отступ', 'вёрстка', 'мобильный']);
  });
  it('ребро from зависит от to', () => {
    expect(buildDecisionGraph(SECTIONS).edges).toContainEqual({ from: 'вёрстка', to: 'отступ' });
  });
});

describe('validateSections', () => {
  it('валидные секции → ok', () => { expect(validateSections(SECTIONS).ok).toBe(true); });
  it('дубль id → error', () => {
    expect(validateSections([{ id: 'a', dependsOn: [] }, { id: 'a', dependsOn: [] }]).ok).toBe(false);
  });
  it('зависимость от несуществующей секции → error', () => {
    const r = validateSections([{ id: 'a', dependsOn: ['нет-такой'] }]);
    expect(r.ok).toBe(false); expect(r.errors.some((e) => /несуществ/i.test(e))).toBe(true);
  });
  it('пустой id → error', () => {
    expect(validateSections([{ id: '', dependsOn: [] }]).ok).toBe(false);
  });
});
```

- [ ] **Step 2: RED** — `... decision-graph` → `Cannot find module`.

- [ ] **Step 3: Реализация**

```js
#!/usr/bin/env node
/**
 * decision-graph — НОВЫЙ граф зависимостей РЕШЕНИЙ/секций артефакта (дизайн
 * 2026-06-05 §4 #2). Отличен от графа НАВЫКОВ (coverage-machine): тут узлы =
 * секции артефакта, рёбра = «секция зависит от §X». Сменил решение в §X →
 * механически подсвечиваются зависимые секции на перепроверку («тупой наводчик
 * внимания», НЕ мыслитель). Ловит только ОБЪЯВЛЕННЫЕ зависимости. Чистый ($0).
 */

function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }

/** Граф: узлы = id секций, рёбра {from, to} = «from зависит от to» (dependsOn). */
export function buildDecisionGraph(sections) {
  const list = Array.isArray(sections) ? sections : [];
  const nodes = list.map((s) => s.id);
  const edges = [];
  for (const s of list) for (const dep of (s.dependsOn || [])) edges.push({ from: s.id, to: dep });
  return { nodes, edges };
}

/** Валидатор: id непустые+уникальные; каждый dependsOn резолвится в существующую секцию. */
export function validateSections(sections) {
  const errors = [];
  const list = Array.isArray(sections) ? sections : [];
  const ids = new Set();
  list.forEach((s, i) => {
    if (!s || typeof s !== 'object' || !isNonEmptyString(s.id)) { errors.push(`section[${i}].id: непустая строка`); return; }
    if (ids.has(s.id)) errors.push(`section[${i}].id дублируется: ${s.id}`);
    ids.add(s.id);
  });
  for (const s of list) for (const dep of (s?.dependsOn || [])) {
    if (!ids.has(dep)) errors.push(`секция ${s.id}: зависимость от несуществующей §${dep}`);
  }
  return { ok: errors.length === 0, errors };
}
```

- [ ] **Step 4: GREEN** — `... decision-graph` → PASS.
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/decision-graph.mjs tools/decision-graph.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): decision-graph — граф зависимостей решений + валидатор (#2)"
```

---

## Task 2: `decision-graph.mjs` — `dependentsOf` + `impactedBy` (наведение внимания)

**Files:** Modify `tools/decision-graph.mjs`, Test `tools/decision-graph.test.mjs` (Write полного файла + блок)

- [ ] **Step 1: Тест** (импорт +`dependentsOf, impactedBy`, новый блок)

```js
describe('dependentsOf (прямые зависимые)', () => {
  it('кто зависит от отступа', () => {
    expect(dependentsOf(SECTIONS, 'отступ').sort()).toEqual(['вёрстка', 'мобильный']);
  });
  it('от кого никто не зависит → []', () => {
    expect(dependentsOf(SECTIONS, 'мобильный')).toEqual([]);
  });
});

describe('impactedBy (транзитивное наведение внимания; cycle-safe; без самого changedId)', () => {
  it('сменил отступ → перепроверить вёрстку и мобильный', () => {
    expect(impactedBy(SECTIONS, 'отступ').sort()).toEqual(['вёрстка', 'мобильный']);
  });
  it('сменил вёрстку → мобильный', () => {
    expect(impactedBy(SECTIONS, 'вёрстка')).toEqual(['мобильный']);
  });
  it('цикл не зацикливает', () => {
    const cyc = [{ id: 'a', dependsOn: ['b'] }, { id: 'b', dependsOn: ['a'] }];
    expect(impactedBy(cyc, 'a')).toEqual(['b']);
  });
  it('сам changedId не в результате', () => {
    expect(impactedBy(SECTIONS, 'отступ')).not.toContain('отступ');
  });
});
```

- [ ] **Step 2: RED** — `dependentsOf is not a function`.

- [ ] **Step 3: Реализация (добавить функции)**

```js
/** Прямые зависимые от sectionId — секции, чей dependsOn включает sectionId. */
export function dependentsOf(sections, sectionId) {
  return (Array.isArray(sections) ? sections : [])
    .filter((s) => (s.dependsOn || []).includes(sectionId))
    .map((s) => s.id);
}

/**
 * Транзитивное «подсвети на перепроверку»: сменил решение в changedId → ВСЕ
 * секции, что прямо или косвенно зависят от него (наведение внимания). Cycle-safe
 * (seen). Сам changedId не включается.
 */
export function impactedBy(sections, changedId) {
  const result = [];
  const seen = new Set([changedId]);
  const queue = [changedId];
  while (queue.length) {
    const cur = queue.shift();
    for (const dep of dependentsOf(sections, cur)) {
      if (!seen.has(dep)) { seen.add(dep); result.push(dep); queue.push(dep); }
    }
  }
  return result;
}
```

- [ ] **Step 4: GREEN** — PASS.
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/decision-graph.mjs tools/decision-graph.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): decision-graph dependentsOf + impactedBy (наведение внимания)"
```

---

## Task 3: `domain-skill-discipline.mjs` — реальный вызов доменного навыка (#3, K2)

**Files:** Create `tools/domain-skill-discipline.mjs`, Test `tools/domain-skill-discipline.test.mjs`

- [ ] **Step 1: Тест**

```js
import { describe, it, expect } from 'vitest';
import { uncalledDomainSkills, domainCallDiscipline } from './domain-skill-discipline.mjs';

describe('uncalledDomainSkills (рекомендован, но не вызван по журналу — K2)', () => {
  it('все вызваны → []', () => {
    expect(uncalledDomainSkills({ recommendedDomainSkills: ['frontend-design'], invokedSkills: ['frontend-design'] })).toEqual([]);
  });
  it('пересказал вместо вызова → в списке', () => {
    expect(uncalledDomainSkills({ recommendedDomainSkills: ['frontend-design'], invokedSkills: [] })).toEqual(['frontend-design']);
  });
});

describe('domainCallDiscipline', () => {
  it('всё вызвано → ok', () => {
    expect(domainCallDiscipline({ recommendedDomainSkills: ['fd'], invokedSkills: ['fd'] }).ok).toBe(true);
  });
  it('не вызван → not ok + причина про пересказ/K2', () => {
    const r = domainCallDiscipline({ recommendedDomainSkills: ['fd'], invokedSkills: [] });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/не вызван|пересказ|K2/i);
  });
  it('пустые рекомендации → ok', () => {
    expect(domainCallDiscipline({}).ok).toBe(true);
  });
});
```

- [ ] **Step 2: RED** — `Cannot find module`.

- [ ] **Step 3: Реализация**

```js
#!/usr/bin/env node
/**
 * domain-skill-discipline — дисциплина РЕАЛЬНОГО вызова доменного навыка (дизайн
 * 2026-06-05 §4 #3): лучший ловец доменных следствий — сам домен-эксперт
 * (frontend-design про мобильный знает по природе). Поэтому в фазе артефакта
 * доменный навык надо РЕАЛЬНО вызвать (журнал-проверяемо, принцип K2), а не
 * пересказывать «что бы он сказал». Чистый ($0): сверяет рекомендованные
 * доменные навыки с фактически вызванными (по журналу М1, инъекция).
 */

/**
 * Какие доменные навыки рекомендованы, но РЕАЛЬНО не вызваны (по журналу).
 * recommendedDomainSkills — из роутера; invokedSkills — факт журнала (K2).
 */
export function uncalledDomainSkills({ recommendedDomainSkills = [], invokedSkills = [] } = {}) {
  const invoked = new Set(invokedSkills);
  return recommendedDomainSkills.filter((s) => !invoked.has(s));
}

/** Решение дисциплины: все рекомендованные доменные навыки реально вызваны? */
export function domainCallDiscipline({ recommendedDomainSkills = [], invokedSkills = [] } = {}) {
  const uncalled = uncalledDomainSkills({ recommendedDomainSkills, invokedSkills });
  return {
    ok: uncalled.length === 0,
    uncalled,
    reason: uncalled.length === 0
      ? 'все доменные навыки реально вызваны (журнал)'
      : `доменный навык не вызван (пересказ вместо вызова, K2): [${uncalled.join(', ')}]`,
  };
}
```

- [ ] **Step 4: GREEN** — PASS.
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/domain-skill-discipline.mjs tools/domain-skill-discipline.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): domain-skill-discipline — реальный вызов доменного навыка (K2, #3)"
```

---

## Task 4: Финальная регрессия tools-only (R5)

- [ ] `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs"` (`dangerouslyDisableSandbox=true`) → базлайн 2340 + decision-graph (~8) + domain-skill-discipline (~5). Ноль RED. Доложить.

---

## Self-Review (§4 #2 / #3)

| Требование | Где |
|---|---|
| #2 НОВЫЙ граф решений (не графа навыков) | `decision-graph.mjs` (Task 1) |
| #2 сменил решение → подсветка зависимых (наводчик) | `impactedBy` (Task 2) |
| #2 ловит только объявленные зависимости | `dependsOn` явный; `validateSections` ловит висящие (Task 1) |
| #3 реальный вызов доменного навыка журналом (K2) | `domainCallDiscipline` (Task 3) |

**Type-consistency:** `dependsOn`/`impactedBy`/`dependentsOf`/`recommendedDomainSkills`/`invokedSkills`/`uncalled` единые. **Scope:** два независимых чистых примитива; связка в живой поток — дисциплина движка при активации.
