# Машина 7 — Фаза 0: fail-CLOSE-карвут + escape-survivability примитивы — DETAILED

> **For agentic workers:** REQUIRED SUB-SKILL: владелец запретил субагентов/Task/Workflow → исполнять ИНЛАЙН через `superpowers:executing-plans` (batch с чекпойнтами). Steps — checkbox (`- [ ]`).

**Goal:** Заложить фундамент непробиваемости — дисциплинарные хуки при своей ошибке блокируют (fail-CLOSE, не тихо пропускают), а аварийный выход владельца всегда достижим (canonicalAction/normalize тотальны — никогда не бросают).

**Architecture:** Не новый слой. Три точечные правки в существующих модулях + один тотальный wrapper: (1) `enforce-hook-helpers.mjs` — pure `disciplineOutcome` + thin `exitDisciplineDecision` + константы P-7-списка + уточнение контракта `:7`; (2) `path-normalization.mjs` — тотальный `pathNormalizeSafe`; (3) `escape-grant.mjs::canonicalAction` — внешний try + дефолт normalize → `pathNormalizeSafe`. Спека §4.1 правило 1 + правило 7(а). Правила 7(б) panic-ветка и 7(в) escape-honor стражей — **Фаза 2**, не здесь.

**Tech Stack:** Node ESM (`tools/*.mjs`), vitest (tools-only config), pure-core + thin-main, хук-контракт Claude Code (exit 2 = block, exit 0 `{}` = pass).

**Спец-источник:** `docs/superpowers/specs/2026-06-08-router-mentor-machine-7-design.md` §4.1 (правила 1, 7), §12 (инвентарь Фазы 0), §«Поправки разбора плана» P-7.

**Квирки исполнения (подтверждены М1–М7):**
- vitest — через **Bash**, ОДНА абсолютная команда: `node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" [фильтр] --reporter dot`. Несколько фильтров — РАЗДЕЛЬНЫМИ аргументами (`a b`), НЕ `a|b`.
- git — через **PowerShell**, по одной команде, литеральные пути, БЕЗ переменных; коммит `git -C "<wt>" commit -F "<wt>\.scratch\msg.txt"`; «Can't find lefthook in PATH» — шум.
- Тест-файлы: **for-of + it()**, НЕ `it.each` (tdd-real-test-verifier его не распознаёт).
- enforce-tdd-gate: prod-правка существующего `tools/*.mjs` требует ПРЕДШЕСТВУЮЩЕЙ правки соответствующего `*.test.mjs` в этом ходе (RED первым). enforce-tdd-real-test-verifier: дифф теста обязан содержать `it(`+`expect(` и ссылаться на правленый prod-файл.
- `<wt>` = `c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo`

---

## Дизайн-решения (locked — обоснование из audit-context-building 2026-06-08)

- **D1 (правило 1, fail-CLOSE).** Прецедент `enforce-normative-content-rules:201-205` — дисциплина при `catch` зовёт `exitDecision({block:true, ...})`. Выносим в общий **pure** `disciplineOutcome(decideFn)` (тестируемо без `process.exit`) + thin `exitDisciplineDecision`. Наблюдение (`observer-stop-hook`/`cost-stop-hook`) это НЕ использует — остаётся fail-quiet.
- **D2 (P-7 список).** Дисциплинарное подмножество — **константа в коде** (`FAIL_CLOSE_DISCIPLINE_HOOKS`) + отдельная `FAIL_QUIET_OBSERVATION_HOOKS`. Список из плана §«Фаза 0» P-7. `enforce-skill-journaler` добавляется в Фазе 3, поглощённая дисциплина §4.2 — в Фазе 4 (NB-комментарий). Манифест-enforcement (CI-красный при незарегистрированном дисциплинарном хуке) — Фаза 6 (P-6); здесь только константа + disjoint-тест.
- **D3 (правило 7а, тотальность canonicalAction).** `normalizeCommand` (askuser-answer-parser:31-34) уже тотален (split строки не бросает). Реальная дыра: ветки Bash/PowerShell обращаются к `input.command` (бросающий getter/Proxy → throw мимо escape-чека). Закрытие — **внешний try** в `canonicalAction` с детерминированным безопасным ключом `'unknown:'`.
- **D4 (правило 7а, тотальность normalize).** `pathNormalize` обязан **остаться строгим** (стр. 109 `throw e` на не-ENOENT — content-стражи на этом fail-CLOSE'ятся). Добавляем **отдельный** тотальный `pathNormalizeSafe` (на throw → `''`) и делаем его дефолтом `normalizeImpl` в `canonicalAction`. **Нулевой дрейф ключей:** на успехе `pathNormalizeSafe === pathNormalize`; на throw → `''` (идентично прежнему write-branch `catch { norm='' }`). Существующие escape-grant-тесты инжектят `normalizeImpl: ID` (write) либо зовут PS/MCP без opts → не задеты. Write-branch локальный try при этом **удаляется** (тотальность теперь в `pathNormalizeSafe`, не дублируется).
- **D5 (порядок задач).** `escape-grant` (0.2) импортирует `pathNormalizeSafe` из `path-normalization` (0.1) → 0.1 ПЕРВЫМ. `enforce-hook-helpers` (0.3) независим.

## Файловая структура Фазы 0

- Modify: `tools/path-normalization.mjs` (+`pathNormalizeSafe` — тотальный wrapper); Test `tools/path-normalization.test.mjs`
- Modify: `tools/escape-grant.mjs` (`canonicalAction` внешний try + дефолт `normalizeImpl = pathNormalizeSafe`, удалить write-branch локальный try); Test `tools/escape-grant.test.mjs`
- Modify: `tools/enforce-hook-helpers.mjs` (+`disciplineOutcome` pure, +`exitDisciplineDecision` thin, +`FAIL_CLOSE_DISCIPLINE_HOOKS`/`FAIL_QUIET_OBSERVATION_HOOKS`, уточнить контракт-комментарий `:7`); Test `tools/enforce-hook-helpers.test.mjs`

---

### Task 0.0: audit-context-building перед правкой — ВЫПОЛНЕНО

- [x] Прочитаны целиком: `tools/escape-grant.mjs` (canonicalAction:25-40, import pathNormalize:13), `tools/path-normalization.mjs` (pathNormalize:82-118, throw на не-ENOENT:109), `tools/enforce-hook-helpers.mjs` (exitDecision:355-363, контракт:7-8), `tools/askuser-answer-parser.mjs` (normalizeCommand:31-34 — тотален), `tools/enforce-normative-content-rules.mjs:185-209` (fail-CLOSE прецедент), `tools/escape-grant.test.mjs` (write-тесты инжектят ID; PS/MCP без opts). Спека §4.1 правила 1/7, §12. Зафиксировано: `canonicalAction(name,input,{normalizeImpl})`, `exitDecision({block,message})`, отсутствие цикл-импорта. Контекст — без правок.

---

### Task 0.1: path-normalization — тотальный `pathNormalizeSafe` (правило 7а)

**Files:** Modify `tools/path-normalization.mjs`; Test `tools/path-normalization.test.mjs`

- [ ] **Step 1: Написать падающий тест** — добавить describe-блок в `tools/path-normalization.test.mjs`:

```js
import { pathNormalizeSafe } from './path-normalization.mjs';

describe('pathNormalizeSafe тотальна (M7 Фаза 0, правило 7а, SE-I/L6)', () => {
  it('не бросает когда realpath кидает не-ENOENT (EACCES) → ""', () => {
    const boom = () => { const e = new Error('denied'); e.code = 'EACCES'; throw e; };
    const opts = { realpath: boom, homedir: '/h', platform: 'linux', resolve: (p) => p };
    expect(() => pathNormalizeSafe('/x', opts)).not.toThrow();
    expect(pathNormalizeSafe('/x', opts)).toBe('');
  });
  it('успех → тот же результат, что строгий pathNormalize', () => {
    const id = (p) => p;
    const opts = { realpath: id, homedir: '/h', platform: 'linux', resolve: id };
    expect(pathNormalizeSafe('/a/b', opts)).toBe('/a/b');
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Bash: `node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" path-normalization --reporter dot`
Expected: FAIL — `pathNormalizeSafe` не экспортирован.

- [ ] **Step 3: Реализовать минимально** — в конец `tools/path-normalization.mjs`:

```js
/**
 * Тотальный (никогда не бросает) вариант pathNormalize — M7 Фаза 0 (правило 7а, SE-I/L6).
 * Для escape-survivability binding-ключей: если нормализация бросит, escape-чек станет
 * недостижим и баг кирпичит сессию мимо аварийного выхода владельца. Возвращает результат
 * pathNormalize при успехе; '' при любой ошибке (вызывающий трактует пусто как unknown).
 * Content-стражи (read-path-deny/mcp-classification) ПРОДОЛЖАЮТ звать строгий pathNormalize —
 * там FS-ошибка должна всплыть и привести к fail-CLOSE, а не быть проглоченной.
 */
export function pathNormalizeSafe(target, opts = {}) {
  try {
    return pathNormalize(target, opts);
  } catch {
    return '';
  }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Bash: та же команда. Expected: PASS.

- [ ] **Step 5: Коммит** (`<wt>\.scratch\msg.txt`):
```
feat(m7-phase0): pathNormalizeSafe — тотальный normalize (правило 7а)
```
PowerShell: `git -C "<wt>" add "tools/path-normalization.mjs" "tools/path-normalization.test.mjs"` → `git -C "<wt>" commit -F "<wt>\.scratch\msg.txt"`

---

### Task 0.2: escape-grant — тотальность `canonicalAction` (правило 7а)

**Files:** Modify `tools/escape-grant.mjs`; Test `tools/escape-grant.test.mjs`

- [ ] **Step 1: Написать падающий тест** — добавить в `tools/escape-grant.test.mjs`:

```js
describe('canonicalAction тотальна (M7 Фаза 0, правило 7а, SE-I/L6)', () => {
  for (const bad of [undefined, null, 123, true, 'x']) {
    it(`не бросает на мусорном toolName=${String(bad)}`, () => {
      expect(() => canonicalAction(bad, bad)).not.toThrow();
    });
  }
  it('не бросает когда геттер input.command кидает (RED до фикса)', () => {
    const evil = {};
    Object.defineProperty(evil, 'command', { get() { throw new Error('boom'); } });
    expect(() => canonicalAction('Bash', evil)).not.toThrow();
  });
  it('не бросает когда injected normalizeImpl кидает (Write)', () => {
    const boom = () => { throw new Error('boom'); };
    expect(() => canonicalAction('Write', { file_path: '/a' }, { normalizeImpl: boom })).not.toThrow();
  });
  it('регресс: валидные ключи не сломаны', () => {
    expect(canonicalAction('Bash', { command: 'git status' })).toBe('bash:git status');
    expect(canonicalAction('PowerShell', { command: 'Get-ChildItem' })).toBe('powershell:Get-ChildItem');
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Bash: `... vitest.mjs run ... escape-grant --reporter dot`
Expected: FAIL — кейс «геттер input.command кидает»: текущая Bash-ветка читает `input.command` без внешнего try → throw наружу → `.not.toThrow()` красный.

- [ ] **Step 3: Реализовать минимально** — в `tools/escape-grant.mjs`:

(а) импорт (строка 13) заменить:
```js
import { pathNormalizeSafe } from './path-normalization.mjs';
```
(б) `canonicalAction` (строки 25-40) переписать — внешний try + дефолт `pathNormalizeSafe` + удалить write-branch локальный try:
```js
/** Каноническая строка действия (binding-ключ). normalizeImpl инъектируем для тестов.
 *  M7 Фаза 0 (правило 7а): тотальна — НИКОГДА не бросает (иначе escape-чек недостижим). */
export function canonicalAction(toolName, toolInput, { normalizeImpl = pathNormalizeSafe } = {}) {
  try {
    const name = String(toolName || '');
    const input = toolInput || {};
    if (name === 'Bash') return `bash:${normalizeCommand(input.command || '')}`;
    // M7 Task 1.2b (P-2): PowerShell несёт команду в input.command (как Bash), не путь записи.
    if (name === 'PowerShell') return `powershell:${normalizeCommand(input.command || '')}`;
    if (name.startsWith('mcp__')) {
      let args; try { args = JSON.stringify(input); } catch { args = String(input); }
      return `mcp:${name}:${normalizeCommand(args)}`;
    }
    const p = extractWritePath(input);
    return `write:${String(normalizeImpl(p) || '')}`;
  } catch {
    // Тотальность (правило 7а, SE-I/L6): любой throw (бросающий getter/Proxy, мусор) →
    // детерминированный безопасный ключ. Вход всегда доходит до escape-чека.
    return 'unknown:';
  }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Bash: та же команда. Expected: PASS (включая существующие PowerShell P-2 / write / mcp кейсы — ключи неизменны).

- [ ] **Step 5: Коммит**:
```
feat(m7-phase0): canonicalAction тотальна — внешний try + pathNormalizeSafe (правило 7а)
```
PowerShell: `git -C "<wt>" add "tools/escape-grant.mjs" "tools/escape-grant.test.mjs"` → commit -F.

---

### Task 0.3: enforce-hook-helpers — fail-CLOSE-карвут (правило 1 + P-7)

**Files:** Modify `tools/enforce-hook-helpers.mjs`; Test `tools/enforce-hook-helpers.test.mjs`

- [ ] **Step 1: Написать падающий тест** — добавить в `tools/enforce-hook-helpers.test.mjs`:

```js
import {
  disciplineOutcome,
  FAIL_CLOSE_DISCIPLINE_HOOKS,
  FAIL_QUIET_OBSERVATION_HOOKS,
} from './enforce-hook-helpers.mjs';

describe('disciplineOutcome — fail-CLOSE (M7 Фаза 0, правило 1)', () => {
  it('decideFn бросает → block:true (fail-CLOSE, не тихий пропуск)', async () => {
    const r = await disciplineOutcome(() => { throw new Error('boom'); }, { label: 'floor' });
    expect(r.block).toBe(true);
    expect(r.message).toMatch(/fail-CLOSE/);
  });
  it('decideFn → {block:false} → пропуск', async () => {
    const r = await disciplineOutcome(() => ({ block: false }));
    expect(r.block).toBe(false);
  });
  it('async decideFn → {block:true,message} → блок с сообщением', async () => {
    const r = await disciplineOutcome(async () => ({ block: true, message: 'нельзя' }));
    expect(r.block).toBe(true);
    expect(r.message).toBe('нельзя');
  });
  it('не-truthy исход (undefined) → block:false', async () => {
    const r = await disciplineOutcome(() => undefined);
    expect(r.block).toBe(false);
  });
});

describe('FAIL_CLOSE / FAIL_QUIET списки (P-7)', () => {
  it('оба непусты', () => {
    expect(FAIL_CLOSE_DISCIPLINE_HOOKS.length).toBeGreaterThan(0);
    expect(FAIL_QUIET_OBSERVATION_HOOKS.length).toBeGreaterThan(0);
  });
  it('дисциплина и наблюдение не пересекаются', () => {
    const q = new Set(FAIL_QUIET_OBSERVATION_HOOKS);
    for (const h of FAIL_CLOSE_DISCIPLINE_HOOKS) expect(q.has(h)).toBe(false);
  });
  it('ядро защиты числится в fail-CLOSE', () => {
    for (const h of ['enforce-floor', 'enforce-supreme-gate', 'enforce-snapshot'])
      expect(FAIL_CLOSE_DISCIPLINE_HOOKS).toContain(h);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Bash: `... vitest.mjs run ... enforce-hook-helpers --reporter dot`
Expected: FAIL — `disciplineOutcome` / списки не экспортированы.

- [ ] **Step 3: Реализовать минимально**

(а) Уточнить контракт-комментарий `tools/enforce-hook-helpers.mjs` строки 7-8 — заменить на:
```js
 * Design contract (M7 Фаза 0, правило 1 — уточнение helpers:7):
 *   - НАБЛЮДАТЕЛЬНЫЕ хуки (FAIL_QUIET_OBSERVATION_HOOKS) — fail-quiet (exit 0, {}).
 *   - ДИСЦИПЛИНАРНЫЕ/ЗАЩИТНЫЕ хуки (FAIL_CLOSE_DISCIPLINE_HOOKS) — fail-CLOSE: любая
 *     внутренняя ошибка → block (exit 2), НЕ тихий пропуск (иначе баг хука = тихий
 *     обход дисциплины, SE2). Используют disciplineOutcome / exitDisciplineDecision.
 *     Прецедент: enforce-normative-content-rules:201-205.
 * Only deliberate violations (or discipline-hook internal errors) exit 2.
```

(б) Рядом с `exitDecision` (строка ~363) добавить:
```js
/**
 * Дисциплинарный исход (pure, тестируемо): выполнить decideFn; ЛЮБАЯ внутренняя
 * ошибка → fail-CLOSE (block:true), НЕ тихий пропуск (M7 Фаза 0, правило 1).
 * Наблюдательные хуки это НЕ используют — остаются fail-quiet.
 */
export async function disciplineOutcome(decideFn, { label = 'discipline' } = {}) {
  try {
    const o = await decideFn();
    return { block: !!(o && o.block), message: o && o.message };
  } catch {
    return { block: true, message: `[${label}] внутренняя ошибка — fail-CLOSE` };
  }
}

/** Thin-main: дисциплинарный exit. Ошибка decideFn → блок (fail-CLOSE). */
export async function exitDisciplineDecision(decideFn, opts = {}) {
  exitDecision(await disciplineOutcome(decideFn, opts));
}

/**
 * P-7 (M7 Фаза 0): дисциплинарные/защитные хуки обязаны быть fail-CLOSE; наблюдательные —
 * fail-quiet. Манифест-тест (Фаза 6 / P-6) сверит, что каждый зарегистрированный
 * дисциплинарный хук числится здесь и использует fail-CLOSE-обёртку.
 * NB: enforce-skill-journaler добавляется в Фазе 3; поглощённая дисциплина §4.2 — в Фазе 4.
 */
export const FAIL_CLOSE_DISCIPLINE_HOOKS = Object.freeze([
  'enforce-floor',
  'enforce-supreme-gate',
  'enforce-judge-gate',
  'enforce-snapshot',
  'enforce-floor-escape-consume',
  'enforce-read-path-deny',
  'enforce-mcp-classification',
  'enforce-normative-content-rules',
]);

export const FAIL_QUIET_OBSERVATION_HOOKS = Object.freeze([
  'observer-stop-hook',
  'cost-stop-hook',
]);
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Bash: та же команда. Expected: PASS.

- [ ] **Step 5: Коммит**:
```
feat(m7-phase0): disciplineOutcome fail-CLOSE + P-7 списки + контракт helpers:7 (правило 1)
```
PowerShell: `git -C "<wt>" add "tools/enforce-hook-helpers.mjs" "tools/enforce-hook-helpers.test.mjs"` → commit -F.

---

### Task 0.4: Регрессия Фазы 0 + per-phase smoke + acceptance

- [ ] **Step 1: Полная регрессия tools-only**

Bash: `node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" --reporter dot`
Expected: ≥ **3044 passed + 2 skip** (baseline Фазы 1) + новые Фазы 0 (~12). 0 regressions.

- [ ] **Step 2: Smoke — acceptance §4.1**

Подтвердить в выводе зелёными: `disciplineOutcome` «decideFn бросает → block:true» (правило 1 — дисциплина fail-CLOSE); `canonicalAction` «геттер кидает → не бросает» + «валидные ключи не сломаны» (правило 7а — тотальность); `pathNormalizeSafe` «realpath EACCES → '' без throw» (правило 7а); P-7 disjoint. Строгий `pathNormalize` НЕ изменён (content-стражи сохраняют FS-error-surface).

- [ ] **Step 3: Финальный коммит фазы (если оставались несакоммиченные правки)**:
```
chore(m7-phase0): Фаза 0 закрыта — fail-CLOSE-карвут + escape-survivability примитивы, регрессия GREEN
```

**Acceptance Фазы 0:** дисциплинарный исход на ошибке → block (правило 1, прецедент `normative-content-rules:203`); `canonicalAction` тотальна — escape-чек всегда достижим (правило 7а); `pathNormalizeSafe` тотален, строгий `pathNormalize` сохранён для content-стражей; P-7-списки заданы и disjoint (основа манифест-теста Фазы 6); регрессия ≥ baseline. → разблокирует Фазу 2 (panic-ветка + escape-honor стражей опираются на эти примитивы).

---

## Гейт закрытия фазы (Слой 2 цепочки скилов)

`audit-context-building` (выполнен Task 0.0) → `sharp-edges` (после Task 0.2/0.3 — опасные ветки: тотальность маскирует баги? escape-привязка не ослаблена?) → `variant-analysis` (siblings: другие потребители `canonicalAction`/`exitDecision` — floor-decide/supreme-gate/consume — не задеты ли новым дефолтом/контрактом) → `regression` tools-only ≥ baseline → `verification-before-completion` → коммит. НЕ `requesting-code-review` (субагент запрещён) — инлайн-адверсариал.

## Self-Review (по чеклисту writing-plans)

**1. Spec coverage:** §4.1 правило 1 → Task 0.3 (disciplineOutcome + контракт :7 + P-7); §4.1 правило 7(а) тотальность canonicalAction → Task 0.2, тотальность normalize → Task 0.1; §12 «дисциплинарное подмножество» → Task 0.3 P-7 списки. Правила 7(б/в) panic+escape-honor → осознанно НЕ здесь (Фаза 2, см. граф). ✓

**2. Placeholder scan:** полный код в каждом шаге, без TBD. ✓

**3. Type consistency:** `disciplineOutcome(decideFn,{label})→Promise<{block,message}>` зовётся thin `exitDisciplineDecision`; `pathNormalizeSafe(target,opts)→string` (тот же контракт что `pathNormalize`, '' на throw) дефолт `normalizeImpl` в `canonicalAction(name,input,{normalizeImpl})→string`; `FAIL_CLOSE_DISCIPLINE_HOOKS`/`FAIL_QUIET_OBSERVATION_HOOKS` — frozen string[]. Дефолт normalizeImpl сменён pathNormalize→pathNormalizeSafe; нулевой дрейф ключей доказан (D4). ✓
