# Sub-plan D — дисциплина чтения 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/reading-discipline.mjs`, который даёт дисциплину чтения наставника (§5.8/§6.7/§5.7 спеки): (1) ДЁШЕВО определяет ТИП текста по метаданным (ДР-5) и отдаёт тип роутеру — **сам скил-читалку выбирает роутер, не этот модуль**; (2) различает ВИД чтения (граф-карта / авторское сырьё / критик-проверка / harness), который `isObserveOnly` не различает; (3) применяет гейт ДР-1 ТОЛЬКО на авторское сырьё-чтение в impl-режиме; (4) ведёт разговорный read-LOG (сигнал владельцу о фронт-лоаде сырья); (5) ограничивает критик-чтение наставника (`MENTOR_PROBE_CAP=2`).

**Architecture:** Чистое ядро по образцу `tools/context-verity.mjs` / `tools/project-graph.mjs` / `tools/router-engine.mjs` (`llmCall` инъектируется) — никакого прямого I/O, всё состояние/предикаты/тексты инъектируются. **Грань ответственности (по коду, не выдумка):** выбор скила-читалки и обработку «скила под тип нет» делает УЖЕ существующий роутер — кандидаты обязаны быть реальными узлами каталога `[router-engine.mjs:113]`, выдуманные отбраковывает `groundTrace` `[router-engine.mjs:148-149]`, а при неуверенности роутер ВОЗДЕРЖИВАЕТСЯ (5.2) `[router-engine.mjs:152-153]`. Поэтому D лишь определяет ТИП и при неоднозначности типа сам возвращает abstain (зеркало 5.2) — никаких выдуманных «дефолт-читалок». Wiring `decideReadEvent` в стену/шов наставника (`enforce-supreme-gate` / `router-engine`) ОТЛОЖЕН в sub-plan C.

**Tech Stack:** Node ESM (`.mjs`), vitest (tools-config). Источник истины спеки: `docs/superpowers/specs/2026-06-10-router-mentor-live-rebuild-design.md` §5.8 (типизация чтений CD-R6-B) / §6.7 (выбор reading-скила ДР-5) / §5.7 (bounded probe ✅O19) / SE-R7-5 (conversational read-LOG).

**Сверенные факты кода (построчно, не перефантазировано):**
- `isObserveOnly` `[enforce-supreme-gate.mjs:41-64]` пускает `Read`/`Grep`/`Glob` БЕЗУСЛОВНО `[:43]` + `TodoWrite` `[:44]` + readonly-Bash `[:46-60]`. **Имя Read-tool НЕ отличает вид-2 (авторское сырьё) от вида-4 (harness)** → именно это закрывает Task 3-4.
- Closed-list паттерн — `LEVEL_SKIP_CATEGORIES = Object.freeze([...])` + `validateLevelSkip` `[router-engine.mjs:29-35]`; `TRACE_SLOTS`/`validateTrace` `[:48-60]`. Образец для `READING_CONTENT_TYPES`/`READ_KINDS`.
- Воздержание 5.2 — `runRouter` при `confidence < threshold` → `{ok:true, abstain:true, reason:'низкая уверенность — воздержание (5.2): переспросить/эскалация'}` `[router-engine.mjs:152-153]`. D-классификатор типа возвращает abstain в той же логике (неоднозначно → переспрос).
- Граф-карта (вид-1) = путь junction `.claude/worktrees/graphify-spike/graphify-out/` (известен фактом; см. sub-plan B).

**Scope (граница):**
- **В D:** `tools/reading-discipline.mjs` (чистый) + `tools/reading-discipline.test.mjs`. Функции/константы: `READING_CONTENT_TYPES`, `classifyReadingContent`, `grepHeaderFallback`, `READ_KINDS`, `classifyReadKind`, `readingGateDecision`, `recordRead`, `readLogSignal`, `MENTOR_PROBE_CAP`, `checkProbeCap`, `decideReadEvent`.
- **НЕ в D (→ sub-plan C):** wiring `decideReadEvent` в `enforce-supreme-gate.decide` (impl-режим гейта ДР-1) + передача `contentType` в `runRouter` для выбора reading-скила + связка с frozenPlan/plan-step (`verifyFrozenPlan`/`actionMatchesStep`). D отдаёт чистые деки́деры, готовые к подключению.
- **НЕ в D:** выбор скила и «нет скила → воздержание» — это РОУТЕР `[router-engine.mjs:113,148-149,152-153]`, уже существует. D не дублирует.

**Квирк среды (ОБЯЗАТЕЛЬНО для шагов «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-стены (или субагент per Pravila §15.1). Команды коммитов даны как есть.

---

### Task 1: `READING_CONTENT_TYPES` + `classifyReadingContent` — тип текста из метаданных (ДР-5)

**Files:**
- Create: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§6.7 ДР-5.** Тип из дешёвых метаданных (расширение + путь + узел графа). Уверенно определяемые → тип; неоднозначно/неизвестно → `{abstain:true}` (зеркало воздержания 5.2 `[router-engine.mjs:152-153]`). **Скил под тип выбирает роутер** (вне D). Консервативно: НЕ выдумываем эвристик «legal-путь» — что метаданные не покрывают, уходит в grep-fallback (Task 2) → иначе abstain.

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

```js
// tools/reading-discipline.test.mjs
import { describe, it, expect } from 'vitest';
import { classifyReadingContent, READING_CONTENT_TYPES } from './reading-discipline.mjs';

describe('READING_CONTENT_TYPES', () => {
  it('заморожен и содержит ожидаемые типы', () => {
    expect(Object.isFrozen(READING_CONTENT_TYPES)).toBe(true);
    for (const t of ['code', 'spec-design', 'narrative-doc', 'legal', 'scientific', 'data', 'config'])
      expect(READING_CONTENT_TYPES).toContain(t);
  });
});

describe('classifyReadingContent (ДР-5, машинно)', () => {
  it('код по расширению → code', () => {
    expect(classifyReadingContent({ ext: '.mjs', path: 'tools/x.mjs' }).contentType).toBe('code');
    expect(classifyReadingContent({ ext: '.php', path: 'app/app/Services/X.php' }).contentType).toBe('code');
    expect(classifyReadingContent({ ext: '.vue', path: 'app/resources/js/X.vue' }).contentType).toBe('code');
  });
  it('данные → data, конфиг → config', () => {
    expect(classifyReadingContent({ ext: '.jsonl', path: 'docs/observer/episodes.jsonl' }).contentType).toBe('data');
    expect(classifyReadingContent({ ext: '.yaml', path: 'app/deptrac.yaml' }).contentType).toBe('config');
  });
  it('md в spec-папке → spec-design', () => {
    expect(classifyReadingContent({ ext: '.md', path: 'docs/superpowers/specs/x-design.md' }).contentType).toBe('spec-design');
    expect(classifyReadingContent({ ext: '.md', path: 'docs/adr/ADR-018.md' }).contentType).toBe('spec-design');
  });
  it('graphNodeType=code подтверждает code даже при неясном ext', () => {
    expect(classifyReadingContent({ ext: '', path: 'x', graphNodeType: 'code' }).contentType).toBe('code');
  });
  it('md вне известных spec-папок → abstain (не выдумывать legal/narrative)', () => {
    const r = classifyReadingContent({ ext: '.md', path: 'docs/random.md' });
    expect(r.abstain).toBe(true);
    expect(r.contentType).toBe(null);
  });
  it('неизвестное расширение → abstain', () => {
    const r = classifyReadingContent({ ext: '.xyz', path: 'a/b.xyz' });
    expect(r.abstain).toBe(true);
  });
});
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: FAIL — `Cannot find module './reading-discipline.mjs'`.

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

```js
// tools/reading-discipline.mjs
#!/usr/bin/env node
/**
 * reading-discipline (§5.8/§6.7/§5.7) — дисциплина чтения наставника.
 * D определяет ТИП текста (ДР-5) и ВИД чтения; СКИЛ-читалку и «нет скила → воздержание»
 * выбирает РОУТЕР [router-engine.mjs:113,148-149,152-153] — D не дублирует и не выдумывает.
 * Чистое ядро (всё инъектируется). Wiring в стену/шов — sub-plan C.
 */

/** Закрытый список типов контента (образец LEVEL_SKIP_CATEGORIES). */
export const READING_CONTENT_TYPES = Object.freeze([
  'code', 'spec-design', 'narrative-doc', 'legal', 'scientific', 'data', 'config',
]);

const CODE_EXT = new Set(['.mjs', '.js', '.ts', '.tsx', '.vue', '.php', '.py', '.go', '.rs', '.sql', '.sh']);
const DATA_EXT = new Set(['.json', '.jsonl', '.csv', '.ndjson']);
const CONFIG_EXT = new Set(['.yaml', '.yml', '.toml', '.ini', '.env', '.neon']);
const SPEC_PATH_PREFIXES = ['docs/superpowers/', 'docs/adr/'];

/**
 * Тип контента из дешёвых метаданных (ext + path + graphNodeType). Уверенно →
 * {contentType, abstain:false}; неоднозначно/неизвестно → {contentType:null, abstain:true}
 * (зеркало воздержания 5.2 — роутер/владелец доразрешат). НЕ выдумываем legal/narrative
 * по пути — что метаданные не покрывают, уходит в grepHeaderFallback (Task 2) → иначе abstain.
 */
export function classifyReadingContent({ ext = '', path = '', graphNodeType = '' } = {}) {
  const e = String(ext || '').toLowerCase();
  const p = String(path || '');
  if (graphNodeType === 'code' || CODE_EXT.has(e)) return { contentType: 'code', abstain: false };
  if (DATA_EXT.has(e)) return { contentType: 'data', abstain: false };
  if (CONFIG_EXT.has(e)) return { contentType: 'config', abstain: false };
  if (e === '.md' && SPEC_PATH_PREFIXES.some((x) => p.startsWith(x))) return { contentType: 'spec-design', abstain: false };
  return { contentType: null, abstain: true, reason: 'тип не определён по метаданным — grep-fallback/переспрос (5.2)' };
}
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: PASS — 7 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): classifyReadingContent тип из метаданных (ДР-5, sub-plan D Task 1)"
```

---

### Task 2: `grepHeaderFallback` — добор типа по заголовку

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§6.7 grep-заголовок fallback.** При abstain от Task 1 (напр. `.md` вне spec-папок) — добрать тип по первым строкам файла (инъектируются как `headerText`). Не помог → `null` (→ воздержание 5.2). Эвристики ТОЛЬКО по реальным маркерам заголовка, не по выдуманным путям.

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

```js
// добавить в reading-discipline.test.mjs
import { grepHeaderFallback } from './reading-discipline.mjs';

describe('grepHeaderFallback', () => {
  it('заголовок плана → spec-design', () => {
    expect(grepHeaderFallback({ headerText: '# Foo Implementation Plan\n> For agentic workers' })).toBe('spec-design');
  });
  it('маркеры закона → legal', () => {
    expect(grepHeaderFallback({ headerText: 'Федеральный закон № 152-ФЗ\nСтатья 1.' })).toBe('legal');
  });
  it('нет маркеров → null (→ воздержание)', () => {
    expect(grepHeaderFallback({ headerText: 'просто какой-то текст без маркеров' })).toBe(null);
  });
  it('пустой/не-строка → null', () => {
    expect(grepHeaderFallback({ headerText: '' })).toBe(null);
    expect(grepHeaderFallback({})).toBe(null);
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/**
 * Добор типа по первым строкам файла (headerText инъектируется — caller грепает).
 * Маркеры консервативны и явны; нет совпадения → null (→ воздержание 5.2 у роутера/владельца).
 */
export function grepHeaderFallback({ headerText = '' } = {}) {
  const t = String(headerText || '');
  if (!t.trim()) return null;
  if (/Implementation Plan|## §|design[- ]notes|spec\b/i.test(t)) return 'spec-design';
  if (/Федеральн\w+ закон|№\s*\d+-ФЗ|\bСтатья\s+\d+|\bдоговор\b/i.test(t)) return 'legal';
  return null;
}
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: PASS — 11 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): grepHeaderFallback добор типа (sub-plan D Task 2)"
```

---

### Task 3: `READ_KINDS` + `classifyReadKind` — различить вид чтения (CD-R6-B)

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§5.8 CD-R6-B.** `isObserveOnly` `[enforce-supreme-gate.mjs:43]` пускает все Read одинаково — D различает 4 вида. Предикаты инъектируются (C свяжет с frozenPlan/plan-step/mentor-seam): `graphPathPrefix` (граф-карта), `planAuthorizesPath` (шаг плана авторизует путь), `isCriticProbe` (наставник пометил проверочное чтение).

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

```js
// добавить в reading-discipline.test.mjs
import { classifyReadKind, READ_KINDS } from './reading-discipline.mjs';

describe('classifyReadKind (4 вида, §5.8)', () => {
  const gp = '.claude/worktrees/graphify-spike/graphify-out/';
  it('READ_KINDS заморожен', () => {
    expect(Object.isFrozen(READ_KINDS)).toBe(true);
    expect(READ_KINDS).toEqual(['graph-map', 'authorial-raw', 'critic-probe', 'harness-mandatory']);
  });
  it('путь в graphify-out → graph-map (вид-1)', () => {
    const k = classifyReadKind({ path: gp + 'graph.json', frozenPlan: true, graphPathPrefix: gp });
    expect(k).toBe('graph-map');
  });
  it('frozenPlan + шаг плана авторизует путь → harness-mandatory (вид-4)', () => {
    const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, planAuthorizesPath: () => true, graphPathPrefix: gp });
    expect(k).toBe('harness-mandatory');
  });
  it('наставник пометил критик-проверку → critic-probe (вид-3)', () => {
    const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true, planAuthorizesPath: () => false, graphPathPrefix: gp });
    expect(k).toBe('critic-probe');
  });
  it('frozenPlan + НЕ шаг плана + не probe → authorial-raw (вид-2, мишень гейта)', () => {
    const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: true, planAuthorizesPath: () => false, graphPathPrefix: gp });
    expect(k).toBe('authorial-raw');
  });
  it('разговорный (нет frozenPlan), сырьё → authorial-raw (но гейт его не блокирует — Task 4)', () => {
    const k = classifyReadKind({ path: 'tools/x.mjs', frozenPlan: false, graphPathPrefix: gp });
    expect(k).toBe('authorial-raw');
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/** 4 вида чтения (§5.8). Порядок = приоритет различения. */
export const READ_KINDS = Object.freeze(['graph-map', 'authorial-raw', 'critic-probe', 'harness-mandatory']);

const DEFAULT_GRAPH_PREFIX = '.claude/worktrees/graphify-spike/graphify-out/';

/**
 * Различить вид чтения (CD-R6-B). Имя Read-tool не различает вид-2 от вид-4 →
 * различаем по предикатам (инъектируемым; C свяжет с frozenPlan/plan-step/seam).
 * Приоритет: граф-карта → авторизованный-шагом (harness) → критик-проверка → авторское сырьё.
 */
export function classifyReadKind({ path = '', frozenPlan = false, planAuthorizesPath = null,
  isCriticProbe = false, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
  const p = String(path || '');
  if (p.startsWith(graphPathPrefix)) return 'graph-map';
  if (frozenPlan && typeof planAuthorizesPath === 'function' && planAuthorizesPath(p)) return 'harness-mandatory';
  if (isCriticProbe) return 'critic-probe';
  return 'authorial-raw';
}
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: PASS — 17 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): classifyReadKind 4 вида чтения (CD-R6-B, sub-plan D Task 3)"
```

---

### Task 4: `readingGateDecision` — гейт ДР-1 только на вид-2 в impl-режиме

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§5.8.** Виды 1/3/4 — свободны. Вид-2 (авторское сырьё): impl-режим (frozenPlan) → БЛОК (гейт ДР-1); разговорный (нет frozenPlan) → НЕ блок, а сигнал (read-LOG, SE-R7-5). Не блокируем легитимное чтение карты/проверки/harness; не кирпичим разговорный режим.

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

```js
// добавить в reading-discipline.test.mjs
import { readingGateDecision } from './reading-discipline.mjs';

describe('readingGateDecision (§5.8 impl-only)', () => {
  it('граф-карта/probe/harness → не блок', () => {
    for (const rk of ['graph-map', 'critic-probe', 'harness-mandatory'])
      expect(readingGateDecision({ readKind: rk, frozenPlan: true }).block).toBe(false);
  });
  it('авторское сырьё в impl-режиме (frozenPlan) → БЛОК (гейт ДР-1)', () => {
    const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: true });
    expect(d.block).toBe(true);
    expect(d.reason).toMatch(/ДР-1/);
  });
  it('авторское сырьё в разговорном (нет frozenPlan) → НЕ блок, но сигнал', () => {
    const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: false });
    expect(d.block).toBe(false);
    expect(d.signal).toBe(true);
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/**
 * Гейт ДР-1 (§5.8): блок ТОЛЬКО на авторское сырьё-чтение (вид-2) в impl-режиме
 * (frozenPlan). Виды 1/3/4 свободны. Разговорный режим — НЕ блок, а сигнал владельцу
 * (read-LOG, SE-R7-5: фронт-лоад сырья до заморозки). @returns {{block, reason, signal}}
 */
export function readingGateDecision({ readKind, frozenPlan = false } = {}) {
  if (readKind !== 'authorial-raw') return { block: false, reason: 'не авторское сырьё — свободно', signal: false };
  if (frozenPlan) return { block: true, reason: 'авторское сырьё-чтение вне шага плана — гейт ДР-1 (impl-режим)', signal: false };
  return { block: false, reason: 'разговорный режим — не блок', signal: true };
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): readingGateDecision ДР-1 impl-only (sub-plan D Task 4)"
```

---

### Task 5: `recordRead` + `readLogSignal` — разговорный read-LOG (SE-R7-5)

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§5.8 SE-R7-5.** В разговорном режиме гейт ДР-1 не блокирует → стимул фронт-лоадить сырьё ДО заморозки. Лечится read-LOG: накапливаем разговорные авторские сырьё-чтения, при превышении порога — сигнал владельцу (не блок). Чистый аккумулятор (immutable).

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

```js
// добавить в reading-discipline.test.mjs
import { recordRead, readLogSignal } from './reading-discipline.mjs';

describe('read-LOG (SE-R7-5)', () => {
  it('копит только разговорные авторские сырьё-чтения', () => {
    let s = { reads: [] };
    s = recordRead(s, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
    s = recordRead(s, { path: 'b.mjs', readKind: 'graph-map', frozenPlan: false });      // не считается
    s = recordRead(s, { path: 'c.mjs', readKind: 'authorial-raw', frozenPlan: true });   // impl — не разговорный
    expect(s.reads.map((r) => r.path)).toEqual(['a.mjs']);
  });
  it('immutable — не мутирует вход', () => {
    const s0 = { reads: [] };
    recordRead(s0, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false });
    expect(s0.reads).toEqual([]);
  });
  it('сигнал при превышении порога', () => {
    const s = { reads: [{ path: '1' }, { path: '2' }, { path: '3' }] };
    const sig = readLogSignal(s, { threshold: 2 });
    expect(sig.frontLoadCount).toBe(3);
    expect(sig.warn).toBe(true);
  });
  it('нет сигнала под порогом', () => {
    expect(readLogSignal({ reads: [{ path: '1' }] }, { threshold: 2 }).warn).toBe(false);
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/**
 * Записать чтение в разговорный read-LOG (SE-R7-5). Считается ТОЛЬКО разговорное
 * (frozenPlan=false) авторское сырьё (readKind='authorial-raw') — это фронт-лоад сырья
 * до заморозки. Immutable: возвращает НОВОЕ состояние. @returns {{reads:[{path}]}}
 */
export function recordRead(logState, { path = '', readKind, frozenPlan = false } = {}) {
  const reads = Array.isArray(logState && logState.reads) ? logState.reads : [];
  if (readKind === 'authorial-raw' && !frozenPlan) return { reads: [...reads, { path: String(path || '') }] };
  return { reads: [...reads] };
}

/**
 * Сигнал владельцу по read-LOG: фронт-лоад сырья превысил порог → warn (НЕ блок).
 * @returns {{frontLoadCount, warn, message}}
 */
export function readLogSignal(logState, { threshold = 5 } = {}) {
  const n = Array.isArray(logState && logState.reads) ? logState.reads.length : 0;
  const warn = n > threshold;
  return {
    frontLoadCount: n,
    warn,
    message: warn ? `read-LOG: ${n} разговорных сырьё-чтений до заморозки (фронт-лоад, SE-R7-5)` : '',
  };
}
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: PASS — 24 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): read-LOG фронт-лоад сигнал (SE-R7-5, sub-plan D Task 5)"
```

---

### Task 6: `MENTOR_PROBE_CAP` + `checkProbeCap` — лимит критик-чтения (✅O19)

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**§5.7 ✅O19.** Критик-чтение наставника (вид-3) — `MENTOR_PROBE_CAP=2` файла/раздела на КРУГ (подозрение + сосед); ×`MENTOR_ROUND_CAP=3` (живёт в C, состояние кругов) → макс 6/задача. НЕ «малый бюджет» аспирационно — жёсткий счётчик, иначе вид-3 станет free-reading backdoor мимо ДР-1.

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

```js
// добавить в reading-discipline.test.mjs
import { checkProbeCap, MENTOR_PROBE_CAP } from './reading-discipline.mjs';

describe('probe-cap (✅O19)', () => {
  it('MENTOR_PROBE_CAP = 2', () => {
    expect(MENTOR_PROBE_CAP).toBe(2);
  });
  it('под лимитом → allowed', () => {
    expect(checkProbeCap(0).allowed).toBe(true);
    expect(checkProbeCap(1).allowed).toBe(true);
  });
  it('на лимите и выше → НЕ allowed', () => {
    expect(checkProbeCap(2).allowed).toBe(false);
    expect(checkProbeCap(5).allowed).toBe(false);
  });
  it('кастомный cap уважается', () => {
    expect(checkProbeCap(2, 3).allowed).toBe(true);
  });
  it('битый счётчик → не allowed (fail-closed)', () => {
    expect(checkProbeCap('x').allowed).toBe(false);
    expect(checkProbeCap(undefined).allowed).toBe(false);
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/** ✅O19: лимит критик-чтения наставника на КРУГ (×MENTOR_ROUND_CAP в C → макс 6/задача). */
export const MENTOR_PROBE_CAP = 2;

/**
 * Можно ли наставнику ещё одно критик-чтение в этом круге. probeCountThisRound —
 * сколько уже использовано. fail-closed: не-число → не allowed (не открыть backdoor).
 * @returns {{allowed, reason}}
 */
export function checkProbeCap(probeCountThisRound, cap = MENTOR_PROBE_CAP) {
  if (!Number.isInteger(probeCountThisRound) || probeCountThisRound < 0)
    return { allowed: false, reason: 'битый счётчик probe — fail-closed' };
  return probeCountThisRound < cap
    ? { allowed: true, reason: `критик-чтение ${probeCountThisRound + 1}/${cap}` }
    : { allowed: false, reason: `лимит критик-чтения исчерпан (${cap}/круг) — к владельцу/след. круг` };
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): probe-cap MENTOR_PROBE_CAP=2 (O19, sub-plan D Task 6)"
```

---

### Task 7: `decideReadEvent` — сборка (готова к wiring в C)

**Files:**
- Modify: `tools/reading-discipline.mjs`
- Test: `tools/reading-discipline.test.mjs`

**Сборка деки́деров в один pure-вход** — то, что sub-plan C врежет в стену/шов. Для одного read-события возвращает: тип контента (→ роутер выберет скил/воздержится), вид чтения, решение гейта, статус probe-cap (если критик-проверка). Не зовёт роутер и не трогает frozenPlan напрямую — всё инъектируется.

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

```js
// добавить в reading-discipline.test.mjs
import { decideReadEvent } from './reading-discipline.mjs';

describe('decideReadEvent (сборка)', () => {
  const gp = '.claude/worktrees/graphify-spike/graphify-out/';
  it('impl-режим, авторское сырьё кода → block + contentType=code', () => {
    const d = decideReadEvent({
      ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true,
      planAuthorizesPath: () => false, graphPathPrefix: gp,
    });
    expect(d.readKind).toBe('authorial-raw');
    expect(d.content.contentType).toBe('code');
    expect(d.gate.block).toBe(true);
  });
  it('критик-проверка под лимитом → не блок + probe allowed', () => {
    const d = decideReadEvent({
      ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true,
      planAuthorizesPath: () => false, probeCountThisRound: 1, graphPathPrefix: gp,
    });
    expect(d.readKind).toBe('critic-probe');
    expect(d.gate.block).toBe(false);
    expect(d.probe.allowed).toBe(true);
  });
  it('критик-проверка сверх лимита → probe не allowed', () => {
    const d = decideReadEvent({
      ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true, isCriticProbe: true,
      planAuthorizesPath: () => false, probeCountThisRound: 2, graphPathPrefix: gp,
    });
    expect(d.probe.allowed).toBe(false);
  });
  it('граф-карта → свободно, probe не оценивается', () => {
    const d = decideReadEvent({ ext: '.json', path: gp + 'graph.json', frozenPlan: true, graphPathPrefix: gp });
    expect(d.readKind).toBe('graph-map');
    expect(d.gate.block).toBe(false);
    expect(d.probe).toBe(null);
  });
});
```

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

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

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

```js
// добавить в reading-discipline.mjs
/**
 * Сборка для ОДНОГО read-события (готова к wiring в C). Возвращает тип контента
 * (→ роутер выберет скил/воздержится — вне D), вид чтения, решение гейта ДР-1,
 * и статус probe-cap (только для критик-проверки; иначе null). Чистая; всё инъектируется.
 * @returns {{readKind, content, gate, probe:{allowed,reason}|null}}
 */
export function decideReadEvent({ ext = '', path = '', graphNodeType = '', headerText = '',
  frozenPlan = false, planAuthorizesPath = null, isCriticProbe = false,
  probeCountThisRound = 0, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
  let content = classifyReadingContent({ ext, path, graphNodeType });
  if (content.abstain && headerText) {
    const fb = grepHeaderFallback({ headerText });
    if (fb) content = { contentType: fb, abstain: false };
  }
  const readKind = classifyReadKind({ path, frozenPlan, planAuthorizesPath, isCriticProbe, graphPathPrefix });
  const gate = readingGateDecision({ readKind, frozenPlan });
  const probe = readKind === 'critic-probe' ? checkProbeCap(probeCountThisRound) : null;
  return { readKind, content, gate, probe };
}
```

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

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/reading-discipline.test.mjs --reporter dot`
Expected: PASS — 33 passed.

- [ ] **Step 5: Commit**

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "feat(reading-discipline): decideReadEvent сборка готова к wiring C (sub-plan D Task 7)"
```

---

### Task 8: Регрессия tools-vitest GREEN

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

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

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

- [ ] **Step 2: Если RED — systematic-debugging**

При падении — НЕ править наугад: `superpowers:systematic-debugging`, root cause, точечный фикс, повтор Step 1.

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

```bash
git add tools/reading-discipline.mjs tools/reading-discipline.test.mjs
git commit -m "fix(reading-discipline): регрессия tools-vitest GREEN (sub-plan D Task 8)"
```

---

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

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

**SE4 (Task 1/2 синк типов ↔ производители).** `READING_CONTENT_TYPES`: убрать `narrative-doc` (нет производителя — generic .md уходит в abstain→переспрос роутера) ЛИБО добавить производитель. ДОБАВИТЬ производитель `scientific`: `grepHeaderFallback` +маркеры (`\bAbstract\b`, `\bDOI:`, `arXiv`, научное `\bСтатья\b`) → `'scientific'` (роутер → deep-research). После правки: КАЖДЫЙ тип в `READING_CONTENT_TYPES` имеет производителя ИЛИ удалён. Обновить тест «список заморожен и содержит ожидаемые».

**SE5 (Task 3/7 — DANGER контракт инъекций, cross-ref C2-W7).** DANGER-блок в scope D: «`planAuthorizesPath` ОБЯЗАН строиться из frozenPlan+stepPtr — always-true стаб ОТКЛЮЧАЕТ гейт ДР-1 (configuration cliff); `probeCountThisRound` ОБЯЗАН быть реальным per-round счётчиком — дефолт 0 ОТКЛЮЧАЕТ probe-cap». Производитель обоих — C2-W2/W3.

**SE12 (Task 1 .env по basename).** `classifyReadingContent`: config-детект ТАКЖЕ по `path.basename(path) === '.env'` (т.к. `extname('.env')===''`). NEW-тест: `{path:'app/.env'}` (ext='') → `config`. Нота: «dotfiles без расширения ловятся basename'ом».

**V-3 (data-const PLACEHOLDER).** `CODE_EXT`/`SPEC_PATH_PREFIXES` пометить «уточнить под реальный набор расширений/spec-папок проекта».

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

**Self-review D — обновить:** +SE4 (синк типов) / +SE5 (DANGER инъекции) / +SE12 (.env basename) + V-3.

---

## Self-Review (writing-plans)

**1. Spec coverage:**
- §6.7 ДР-5 тип из метаданных + grep-fallback + abstain (а выбор скила/«нет скила→воздержание» — у роутера `[router-engine.mjs:113,148-149,152-153]`, вне D) → Task 1/2. ✅
- §5.8 CD-R6-B типизация 4 видов чтения (то, что `isObserveOnly` не различает) → Task 3. ✅
- §5.8 гейт ДР-1 ТОЛЬКО на вид-2 в impl-режиме; виды 1/3/4 свободны; разговорный → не блок → Task 4. ✅
- §5.8 SE-R7-5 conversational read-LOG (фронт-лоад сигнал) → Task 5. ✅
- §5.7 ✅O19 bounded probe `MENTOR_PROBE_CAP=2` → Task 6. ✅
- Сборка для wiring → Task 7 (wiring в стену/шов → sub-plan C, явно отложено). ✅
- Регрессия → Task 8. ✅
- **Вне D (→ C):** wiring `decideReadEvent` в `enforce-supreme-gate.decide`, передача `contentType` в `runRouter`, связка с frozenPlan/`MENTOR_ROUND_CAP` (состояние кругов). НЕ покрыты намеренно. **Вне D (роутер):** выбор reading-скила и обработка «скила нет» — существующий `runRouter` (воздержание 5.2), D не дублирует.

**2. Placeholder scan:** реальный код в каждом code-шаге; команды Run с ожидаемым выводом; типы контента/маркеры заголовка консервативны (не выдуманы legal-пути); грань с роутером заземлена цитатами кода; нет «TBD». ✅

**3. Type consistency:** `classifyReadingContent`→`{contentType,abstain,reason?}`; `grepHeaderFallback`→string|null; `classifyReadKind`→string∈READ_KINDS; `readingGateDecision`→`{block,reason,signal}`; `recordRead`→`{reads:[{path}]}`; `readLogSignal`→`{frontLoadCount,warn,message}`; `checkProbeCap`→`{allowed,reason}`; `decideReadEvent`→`{readKind,content,gate,probe}`. Имена согласованы. ✅

**Известное ограничение (честно):** D **не выбирает скил-читалку** — по коду это работа роутера (кандидаты обязаны быть реальными узлами каталога `[router-engine.mjs:113]`; выдуманные отбраковываются `[:148-149]`; неуверенность → воздержание `[:152-153]`). D даёт лишь ТИП; «нет скила под тип» закрыто СУЩЕСТВУЮЩИМ воздержанием роутера, а не выдуманной заглушкой (поправка владельца «смотри по коду, не фантазируй»). Реально доступные reading-скилы на момент написания: код → `audit-context-building`, научное → `deep-research`; под закон/художку/данные готового скила нет → роутер честно воздержится и переспросит владельца (или владелец заранее заведёт скил) — это wiring-вопрос C, не дефект D.
