# Машина 6 (Аварийный выход + авто-снимок) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: исполнять **инлайн** через `superpowers:executing-plans` (суб-агенты/Task/Workflow ЗАПРЕЩЕНЫ — ограничение владельца). Шаги — чекбоксы `- [ ]`. Перед правкой КАЖДОГО существующего модуля — `audit-context-building` (раздел «Конвенции»).

**Goal:** Достроить безопасность пола М5 — законный неподделываемый аварийный выход (escape) для владельца на любое заблокированное полом действие + авто-снимок (git-точка возврата) перед разрушительным.

**Architecture:** escape встроен в ядро пола `floor-decide` (замена `approvalOpen`) во всех ветках; одноразовый пропуск `kind:"floor_escape"` пишет среда (обёртка PostToolUse на AskUserQuestion), гасится PostToolUse-консьюмером после исполнения. Авто-снимок — отдельный PreToolUse-хук после пола. Чистые ядра + тонкие обёртки + TDD, как М1–М5.

**Tech Stack:** Node ESM (`tools/*.mjs`), vitest (`app/vitest.config.tools.mjs`), git. Спек: `docs/superpowers/specs/2026-06-07-router-mentor-machine-6-design.md`.

---

## Конвенции (читать ПЕРЕД стартом)

**Корень worktree (`<wt>`):** `c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo`

**Прогон тестов (ОДНА абсолютная команда, без cd/&&):**
```
node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" <фильтр> --reporter dot
```
`<фильтр>` = путь тест-файла, напр. `tools/m6-escape.test.mjs`. Полный прогон — без фильтра; планка ≥ **2789 passed + 2 skip**.

**Коммит (через PowerShell, без переменных, литеральные пути; Bash-гейт PowerShell не матчит):**
```
git -C "<wt>" add <файлы>
git -C "<wt>" commit -F "<wt>\.scratch\msg.txt"
```
(«Can't find lefthook in PATH» — шум.) **Без push** (commit-not-push; пуш только по слову владельца «пуш»).

**TDD-гейт (обязательно):** правка prod-файла `X.mjs` без предшествующего RED-теста в **`X.test.mjs`** → блок. Поэтому per-модульные тесты пишем в **собственный** `*.test.mjs` модуля; кросс-модульные сценарии — в новые `tools/m6-escape.test.mjs` / `tools/m6-snapshot.test.mjs` (новый файл через Write свободен от coverage-требования). `tdd-real-test-verifier`: при Edit тест-файла `new_string` обязан содержать `it(` + `expect(` + строковый литерал-basename правленого prod-модуля; `it.each(` не распознаётся — плоский `it()`.

**audit-context-building:** перед правкой любого существующего модуля (`floor-decide`/`enforce-floor`/`askuser-answer-parser`/`enforce-askuser-answer-parser`/`enforce-mcp-classification`) — построчно прочитать его и зависимые helpers; пропускать ЗАПРЕЩЕНО.

**Каноническая строка действия (binding-ключ, общая для всех мест):** реализуется в Пакете 1 как `canonicalAction(toolName, toolInput, {normalizeImpl})`:
- Bash → `bash:${normalizeCommand(command)}`
- Write/Edit/прочий писатель → `write:${pathNormalize(path)}` (поле пути — как `extractWritePath` в floor-decide)
- MCP egress → `mcp:${toolName}:${normalizeCommand(JSON.stringify(toolInput))}`

Пол/egress-хук строят строку из ЖИВОГО `tool_input`; парсер — из показанного владельцу варианта (токен `FLOOR-ESCAPE: <каноническая строка>`). Совпали → разрешить.

---

## Пакет 1 — Чистое ядро `escape-grant.mjs`

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

- [ ] **Step 1.1 — RED: тест `canonicalAction` + `escapeGrantOpen`**

Write `tools/escape-grant.test.mjs`:
```js
import { describe, it, expect } from 'vitest';
import { canonicalAction, escapeGrantOpen, FLOOR_ESCAPE_WINDOW_MS } from './escape-grant.mjs';

const ID = (s) => s; // normalizeImpl-заглушка для путей

describe('escape-grant canonicalAction', () => {
  it('Bash → bash:<normalized command>', () => {
    expect(canonicalAction('Bash', { command: 'git   push   --force' }, { normalizeImpl: ID }))
      .toBe('bash:git push --force');
  });
  it('Write → write:<normalized path>', () => {
    expect(canonicalAction('Write', { file_path: '/a/.env' }, { normalizeImpl: ID }))
      .toBe('write:/a/.env');
  });
  it('MCP → mcp:<tool>:<args>', () => {
    expect(canonicalAction('mcp__x__send', { url: 'http://1.2.3.4' }, { normalizeImpl: ID }))
      .toBe('mcp:mcp__x__send:{"url":"http://1.2.3.4"}');
  });
});

describe('escape-grant escapeGrantOpen', () => {
  const now = 1_000_000;
  const fresh = (action) => ({ action, ts: now - 1000 });
  it('точное совпадение свежего непогашенного → open', () => {
    expect(escapeGrantOpen('bash:git push --force', [fresh('bash:git push --force')], [], now)).toBe(true);
  });
  it('несовпавшая строка → closed', () => {
    expect(escapeGrantOpen('bash:git push --force', [fresh('bash:reset --hard')], [], now)).toBe(false);
  });
  it('погашенный (action в consumed) → closed (one-shot)', () => {
    const g = fresh('bash:x'); expect(escapeGrantOpen('bash:x', [g], [{ action: 'bash:x', ts: g.ts }], now)).toBe(false);
  });
  it('устаревший (> окна) → closed', () => {
    expect(escapeGrantOpen('bash:x', [{ action: 'bash:x', ts: now - FLOOR_ESCAPE_WINDOW_MS - 1 }], [], now)).toBe(false);
  });
  it('из будущего (ts > now) → closed', () => {
    expect(escapeGrantOpen('bash:x', [{ action: 'bash:x', ts: now + 1000 }], [], now)).toBe(false);
  });
  it('пустой список → closed', () => {
    expect(escapeGrantOpen('bash:x', [], [], now)).toBe(false);
  });
});
```

- [ ] **Step 1.2 — Прогнать, убедиться FAIL**

Run: `node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" tools/escape-grant.test.mjs --reporter dot`
Expected: FAIL (`escape-grant.mjs` не существует).

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

```js
#!/usr/bin/env node
/**
 * escape-grant (Машина 6, Блок 1) — чистое ядро аварийного выхода.
 * Заменяет узкую git-only «дверь владельца» floor-decide.approvalOpen.
 * Пропуск kind:"floor_escape" пишет среда (enforce-askuser-answer-parser на реальный
 * AskUser); контроллер канал не пишет (~/.claude/runtime protected). Одноразовый:
 * гасится PostToolUse-консьюмером после исполнения (floor-escape-consume).
 */
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { normalizeCommand } from './askuser-answer-parser.mjs';
import { pathNormalize } from './path-normalization.mjs';

export const FLOOR_ESCAPE_WINDOW_MS = 5 * 60 * 1000;
const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri'];

function extractWritePath(input) {
  if (!input || typeof input !== 'object') return '';
  for (const f of PATH_FIELDS) if (typeof input[f] === 'string' && input[f]) return input[f];
  return '';
}

/** Каноническая строка действия (binding-ключ). normalizeImpl инъектируем для тестов. */
export function canonicalAction(toolName, toolInput, { normalizeImpl = pathNormalize } = {}) {
  const name = String(toolName || '');
  const input = toolInput || {};
  if (name === 'Bash') return `bash:${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);
  let norm = ''; try { norm = String(normalizeImpl(p) || ''); } catch { norm = ''; }
  return `write:${norm}`;
}

/** Свежий непогашенный пропуск с точным совпадением строки действия? */
export function escapeGrantOpen(action, grants, consumed, now = Date.now()) {
  if (!action || !Array.isArray(grants) || grants.length === 0) return false;
  const isConsumed = (g) => Array.isArray(consumed) && consumed.some(
    (c) => c && c.action === g.action && c.ts === g.ts);
  return grants.some(
    (g) => g && g.action === action && typeof g.ts === 'number'
      && now - g.ts >= 0 && now - g.ts <= FLOOR_ESCAPE_WINDOW_MS && !isConsumed(g),
  );
}

/** I/O: floor_escape-пропуски сессии (зеркало shell-content::loadApprovedGitOps). */
export function loadFloorEscapes(sessionId, now = Date.now()) {
  return loadRecords(sessionId, 'floor_escape', 'action').filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS);
}

/** I/O: отметки-погашения. */
export function loadConsumed(sessionId) {
  const path = join(homedir(), '.claude', 'runtime', `floor-escape-consumed-${sessionId || 'unknown'}.jsonl`);
  if (!existsSync(path)) return [];
  const out = [];
  try {
    for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) {
      if (!line.trim()) continue;
      let r; try { r = JSON.parse(line); } catch { continue; }
      if (r && typeof r.action === 'string' && typeof r.ts === 'number') out.push({ action: r.action, ts: r.ts });
    }
  } catch { return []; }
  return out;
}

function loadRecords(sessionId, type, field) {
  const path = join(homedir(), '.claude', 'runtime', `askuser-decisions-${sessionId || 'unknown'}.jsonl`);
  if (!existsSync(path)) return [];
  const out = [];
  try {
    for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) {
      if (!line.trim()) continue;
      let r; try { r = JSON.parse(line); } catch { continue; }
      if (r && r.type === type && typeof r[field] === 'string') out.push({ [field]: r[field], ts: typeof r.ts === 'number' ? r.ts : 0 });
    }
  } catch { return []; }
  return out;
}
```

- [ ] **Step 1.4 — Прогнать, убедиться PASS** (та же команда; Expected: PASS).

- [ ] **Step 1.5 — Commit**
`.scratch\msg.txt`: `feat(m6): escape-grant pure core — canonicalAction + escapeGrantOpen + readers`
```
git -C "<wt>" add tools/escape-grant.mjs tools/escape-grant.test.mjs
git -C "<wt>" commit -F "<wt>\.scratch\msg.txt"
```

---

## Пакет 2 — Парсер: `toFloorEscapeRecord` (правка `askuser-answer-parser.mjs`)

**Files:**
- Modify: `tools/askuser-answer-parser.mjs`
- Test: `tools/askuser-answer-parser.test.mjs` (существующий)

- [ ] **Step 2.1 — audit-context-building** по `askuser-answer-parser.mjs` (формат `toApprovalRecord`, `normalizeAnswer`, `isStopAnswer`).

- [ ] **Step 2.2 — RED: тест в существующий `askuser-answer-parser.test.mjs`**

Добавить (Edit, `new_string` содержит `it(`+`expect(`+`'askuser-answer-parser'`-нет; basename-литерал — используем имя prod-файла в describe-строке):
```js
import { toFloorEscapeRecord } from './askuser-answer-parser.mjs';
describe('askuser-answer-parser toFloorEscapeRecord', () => {
  it('распознаёт FLOOR-ESCAPE токен → запись floor_escape', () => {
    const r = toFloorEscapeRecord('Разрешаю FLOOR-ESCAPE: bash:git push --force', { nowMs: 5 });
    expect(r).toEqual({ type: 'floor_escape', action: 'bash:git push --force', ts: 5 });
  });
  it('без токена → null', () => {
    expect(toFloorEscapeRecord('просто да', { nowMs: 5 })).toBe(null);
  });
  it('stop-намерение → null', () => {
    expect(toFloorEscapeRecord('отмена FLOOR-ESCAPE: bash:x', { nowMs: 5 })).toBe(null);
  });
});
```

- [ ] **Step 2.3 — Прогнать FAIL** (`... tools/askuser-answer-parser.test.mjs ...`).

- [ ] **Step 2.4 — Реализовать `toFloorEscapeRecord` в `askuser-answer-parser.mjs`**

После `toApprovalRecord` добавить:
```js
const FLOOR_ESCAPE_RE = /FLOOR-ESCAPE:\s*([^\n]+?)\s*$/;

/** Перевести ответ AskUser в floor_escape-запись, либо null. Stop-намерения → null. */
export function toFloorEscapeRecord(answer, { nowMs = Date.now() } = {}) {
  if (typeof answer !== 'string') return null;
  if (isStopAnswer(answer)) return null;
  const m = FLOOR_ESCAPE_RE.exec(answer);
  if (!m) return null;
  const action = m[1].trim();
  if (!action) return null;
  return { type: 'floor_escape', action, ts: nowMs };
}
```

- [ ] **Step 2.5 — Прогнать PASS.**

- [ ] **Step 2.6 — Commit** (`feat(m6): toFloorEscapeRecord — распознавание escape-одобрения`).

---

## Пакет 3 — Писатель: floor_escape в `enforce-askuser-answer-parser.mjs`

**Files:**
- Modify: `tools/enforce-askuser-answer-parser.mjs`
- Test: `tools/enforce-askuser-answer-parser.test.mjs` (существующий)

- [ ] **Step 3.1 — audit-context-building** по `enforce-askuser-answer-parser.mjs` (`processEvent`, runtimeDir override).

- [ ] **Step 3.2 — RED: тест в существующий тест-файл**
```js
describe('enforce-askuser-answer-parser floor_escape', () => {
  it('ответ с FLOOR-ESCAPE пишет floor_escape-запись', () => {
    const dir = mkTmpDir(); // helper как в существующих тестах (или os.tmpdir + rnd по index)
    const ev = { session_id: 's1', tool_input: { questions: [{ question: 'q' }] },
      tool_response: { answers: { q: 'да FLOOR-ESCAPE: bash:reset --hard' } } };
    processEvent(ev, { runtimeDir: dir, nowMs: 7 });
    const content = require('node:fs').readFileSync(require('node:path').join(dir, 'askuser-decisions-s1.jsonl'), 'utf-8');
    expect(content).toContain('"type":"floor_escape"');
    expect(content).toContain('"action":"bash:reset --hard"');
  });
});
```
(Использовать тот же стиль tmp-dir, что в существующем тесте; не `Math.random` — индекс/счётчик.)

- [ ] **Step 3.3 — Прогнать FAIL.**

- [ ] **Step 3.4 — Реализовать: в `processEvent` после ветки `toApprovalRecord` добавить параллельную запись**

В цикле по вопросам, рядом с `const rec = toApprovalRecord(...)`, добавить:
```js
import { toFloorEscapeRecord } from './askuser-answer-parser.mjs'; // к существующему импорту
// ... внутри цикла, после appendFileSync(rec):
const esc = toFloorEscapeRecord(ans, { nowMs });
if (esc) {
  if (!wroteAny) { try { mkdirSync(dirname(path), { recursive: true }); } catch {} wroteAny = true; }
  try { appendFileSync(path, JSON.stringify(esc) + '\n'); } catch {}
}
```
(fail-open сохранить — никогда не бросать из PostToolUse.)

- [ ] **Step 3.5 — Прогнать PASS.**

- [ ] **Step 3.6 — Commit** (`feat(m6): писать floor_escape-пропуск из AskUser-ответа`).

---

## Пакет 4 — Пол: escape во всех ветках (`floor-decide.mjs` + `enforce-floor.mjs`)

**Files:**
- Modify: `tools/floor-decide.mjs` (убрать `approvalOpen`-вызовы → escape), `tools/enforce-floor.mjs` (загрузка пропусков)
- Test: `tools/floor-decide.test.mjs`, `tools/enforce-floor.test.mjs` (существующие)

- [ ] **Step 4.1 — audit-context-building** по `floor-decide.mjs` + `enforce-floor.mjs` + `escape-grant.mjs`.

- [ ] **Step 4.2 — RED: тесты в `floor-decide.test.mjs`**
```js
import { floorDecide } from './floor-decide.mjs';
describe('floor-decide escape (M6)', () => {
  const now = 1000;
  it('Bash-floor с совпавшим escape-пропуском → не блок', () => {
    const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      escapeGrants: [{ action: 'bash:git push --force', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: (x) => x });
    expect(r.block).toBe(false);
  });
  it('Write-floor (.env) с совпавшим escape → не блок (раньше двери не было)', () => {
    const r = floorDecide({ toolUse: { name: 'Write', input: { file_path: '/a/.env' } },
      escapeGrants: [{ action: 'write:/a/.env', ts: now - 10 }], escapeConsumed: [], now, normalizeImpl: (x) => x });
    expect(r.block).toBe(false);
  });
  it('Bash-floor без пропуска → блок', () => {
    const r = floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      escapeGrants: [], escapeConsumed: [], now, normalizeImpl: (x) => x });
    expect(r.block).toBe(true);
  });
});
```

- [ ] **Step 4.3 — Прогнать FAIL.**

- [ ] **Step 4.4 — Реализовать в `floor-decide.mjs`**

Заменить импорт/логику двери: убрать `approvalOpen`-функцию и её вызов; добавить:
```js
import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs';
// сигнатура floorDecide расширяется:
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize }) {
  // ... name/input как было ...
  const escaped = () => escapeGrantOpen(
    canonicalAction(name, input, { normalizeImpl }), escapeGrants, escapeConsumed, now);

  if (name === 'Bash') {
    if (bashIsFloor(input.command || '')) {
      if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом (floor_escape)' };
      return { block: true, reason: 'floor: необратимая команда без аварийного выхода — блок (вето-до-плана)' };
    }
    return { block: false, reason: 'floor: Bash не необратимо' };
  }
  if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' };

  const fp = extractWritePath(input);
  if (!fp) return { block: false, reason: 'floor: нет пути записи' };
  let norm; try { norm = String(normalizeImpl(fp) || ''); } catch { return { block: true, reason: 'floor: путь записи не резолвится — fail-CLOSED' }; }
  const slashed = norm.split('\\\\').join('/');
  if (RUNTIME_RE.test(slashed) || SECRET_PATH_RE.some((re) => re.test(slashed))) {
    if (escaped()) return { block: false, reason: 'floor: запись разрешена аварийным выходом (floor_escape)' };
    return { block: true, reason: 'floor: запись в runtime/секрет без аварийного выхода — блок' };
  }
  return { block: false, reason: 'floor: запись в обычный файл' };
}
```
Удалить экспорт `approvalOpen`. **G-5:** блок-сообщение обязано выводить **точный** `canonicalAction(name, input, {normalizeImpl})` (контроллер копирует его во всплывающий вопрос дословно как `FLOOR-ESCAPE: <строка>` — иначе binding не совпадёт). **G-6:** ни `floor-decide.mjs`, ни `enforce-floor.mjs` не должны содержать подстроку `override` (даже в комментах) — иначе краснеет `m5-floor-invariants.test.mjs` (escape≠protection); используем только термин «escape»/«floor_escape».

- [ ] **Step 4.4b — G-2: переписать блок «дверь владельца Δ1» в `floor-decide.test.mjs`**

Блок `describe('floorDecide — дверь владельца Δ1 ...')` (≈строки 79-110, на `approvedGitOps`) после смены сигнатуры станет красным. **Заменить** его на escape-эквивалент (5 кейсов, сохранить покрытие):
```js
describe('floorDecide — аварийный выход (escape, exact+window+one-shot)', () => {
  const now = 1_000_000;
  const grant = (action, ts = now - 1000) => ({ action, ts });
  it('migrate:fresh с совпавшим свежим пропуском → allow', () => {
    expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
      escapeGrants: [grant('bash:php artisan migrate:fresh')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(false);
  });
  it('пропуск на ЧУЖУЮ строку → block', () => {
    expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
      escapeGrants: [grant('bash:php artisan migrate')], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
  });
  it('просроченный (>5 мин) пропуск → block', () => {
    expect(floorDecide({ toolUse: bash('php artisan db:wipe'),
      escapeGrants: [grant('bash:php artisan db:wipe', now - 6 * 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
  });
  it('пустой список пропусков → block', () => {
    expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'), escapeGrants: [], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
  });
  it('будущий ts пропуска → block (нижняя граница времени)', () => {
    expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
      escapeGrants: [grant('bash:php artisan migrate:fresh', now + 60 * 1000)], escapeConsumed: [], now, normalizeImpl: id }).block).toBe(true);
  });
  it('уже погашенный пропуск (one-shot) → block', () => {
    const g = grant('bash:php artisan migrate:fresh');
    expect(floorDecide({ toolUse: bash('php artisan migrate:fresh'),
      escapeGrants: [g], escapeConsumed: [{ action: g.action, ts: g.ts }], now, normalizeImpl: id }).block).toBe(true);
  });
});
```
(Прежний комментарий-заголовок файла про «дверь Δ1 read-only approve_git_operation» — обновить под escape.)

- [ ] **Step 4.5 — RED+реализация в `enforce-floor.mjs`**

Тест в `enforce-floor.test.mjs`:
```js
import { decide } from './enforce-floor.mjs';
it('enforce-floor пробрасывает escapeGrants в floorDecide', () => {
  const ev = { tool_name: 'Bash', tool_input: { command: 'git push --force' }, session_id: 's' };
  const r = decide({ event: ev, escapeGrants: [{ action: 'bash:git push --force', ts: Date.now() }], escapeConsumed: [], normalizeImpl: (x) => x });
  expect(r.block).toBe(false);
});
```
Реализация — `decide` принимает `escapeGrants`/`escapeConsumed` и передаёт в `floorDecide`; `main()` грузит их:
```js
import { loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
export function decide({ event, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl }) {
  const toolUse = { name: event && event.tool_name, input: (event && event.tool_input) || {} };
  const args = { toolUse, escapeGrants, escapeConsumed, now };
  if (normalizeImpl) args.normalizeImpl = normalizeImpl;
  return floorDecide(args);
}
// main(): const sess = ...; const escapeGrants = loadFloorEscapes(sess); const escapeConsumed = loadConsumed(sess);
//         const r = decide({ event, escapeGrants, escapeConsumed });
```
Убрать импорт/использование `loadApprovedGitOps` в enforce-floor (дверь заменена escape).

- [ ] **Step 4.6 — Прогнать оба тест-файла PASS.**

- [ ] **Step 4.7 — Commit** (`feat(m6): пол — escape во всех ветках, замена approvalOpen`).

---

## Пакет 4b — Сквозной escape в верховной стене (`enforce-supreme-gate.mjs`) [G-1 α]

> **G-1:** стена М2 (`enforce-supreme-gate`, matcher `*`, PreToolUse) независимо блокирует разрушительное in-plan (Δ7, стр.157) и любой мутатор в разговорном режиме. Без этого патча floor_escape (только пол) — no-op сквозь стену. Вариант α: стена честит floor_escape — allow **без** продвижения указателя (escape = out-of-band действие владельца, не шаг плана).

**Files:**
- Modify: `tools/enforce-supreme-gate.mjs`
- Test: `tools/enforce-supreme-gate.test.mjs` (существующий)

- [ ] **Step 4b.1 — audit-context-building** по `enforce-supreme-gate.mjs` (`decide`/`decideMode`/`runGate`/`main`; точка раннего allow).

- [ ] **Step 4b.2 — RED: тесты в `enforce-supreme-gate.test.mjs`**
```js
import { decideMode } from './enforce-supreme-gate.mjs';
describe('supreme-gate escape (M6 G-1 α)', () => {
  const now = 1000;
  it('разрушительное с совпавшим floor_escape (разговорный режим) → allow без advanceTo', () => {
    const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }], escapeConsumed: [], now });
    expect(r.decision).toBe('allow');
    expect(r.advanceTo).toBeUndefined();
  });
  it('без пропуска разговорный мутатор → block (поведение М2 не изменилось)', () => {
    const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      frozenPlan: null, escapeGrants: [], escapeConsumed: [], now });
    expect(r.decision).toBe('block');
  });
  it('погашенный пропуск (one-shot) → block', () => {
    const r = decideMode({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      frozenPlan: null, escapeGrants: [{ action: 'bash:git push --force', ts: now - 5 }],
      escapeConsumed: [{ action: 'bash:git push --force', ts: now - 5 }], now });
    expect(r.decision).toBe('block');
  });
});
```

- [ ] **Step 4b.3 — Прогнать FAIL** (`... tools/enforce-supreme-gate.test.mjs ...`).

- [ ] **Step 4b.4 — Реализовать: ранний escape-allow в `decideMode`**
```js
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';

export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key,
  escapeGrants = [], escapeConsumed = [], now = Date.now(),
  verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
  // G-1 (α): сквозной аварийный выход владельца — раньше всех плановых проверок.
  // allow БЕЗ advanceTo (указатель не двигается; escape — out-of-band, не шаг плана).
  if (escapeGrantOpen(canonicalAction(toolUse?.name, toolUse?.input || {}), escapeGrants, escapeConsumed, now)) {
    return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' };
  }
  // ... существующее тело без изменений ...
}
```
`runGate` — принять `escapeGrants`/`escapeConsumed`/`now` и передать в `decideMode` (escape-allow без `advanceTo` → существующая ветка журналирования/saveStep не срабатывает, указатель не двигается — то что нужно). `main()` — загрузить и передать:
```js
const escapeGrants = loadFloorEscapes(sess);
const escapeConsumed = loadConsumed(sess);
// ... runGate({ ..., escapeGrants, escapeConsumed });
```
(NB: `enforce-supreme-gate` НЕ в override-списке `m5-floor-invariants` — термин escape допустим. Существующие тесты зовут `decideMode` без `escapeGrants` → default `[]` → `escapeGrantOpen` false → поведение М2 не меняется, обратная совместимость.)

- [ ] **Step 4b.5 — Прогнать PASS** + быстрый прогон `enforce-supreme-gate.test.mjs` целиком (старые тесты зелёные — backward-compat).

- [ ] **Step 4b.6 — Commit** (`feat(m6): G-1 сквозной escape в верховной стене М2 (allow без продвижения указателя)`).

---

## Пакет 5 — Одноразовое погашение: `floor-escape-consume.mjs` (PostToolUse)

**Files:**
- Create: `tools/floor-escape-consume.mjs`, `tools/enforce-floor-escape-consume.mjs`
- Test: `tools/floor-escape-consume.test.mjs`

> Реализация спека §4 «погашается после применения … на пути, реально дошедшем до выполнения». Консьюмер — PostToolUse: после фактического исполнения floor-действия с совпавшим пропуском пишет отметку. Сбой снимка (PreToolUse-блок) → действие не исполнилось → PostToolUse не сработал → пропуск НЕ сгорел.

- [ ] **Step 5.1 — RED: `tools/floor-escape-consume.test.mjs`**
```js
import { describe, it, expect } from 'vitest';
import { consumeDecision } from './floor-escape-consume.mjs';
describe('floor-escape-consume', () => {
  const now = 1000;
  it('исполненное действие с непогашенным пропуском → отметка', () => {
    const r = consumeDecision({ action: 'bash:reset --hard',
      grants: [{ action: 'bash:reset --hard', ts: now - 5 }], consumed: [], now });
    expect(r).toEqual({ action: 'bash:reset --hard', ts: now - 5 });
  });
  it('уже погашено → null', () => {
    const r = consumeDecision({ action: 'bash:x', grants: [{ action: 'bash:x', ts: 1 }], consumed: [{ action: 'bash:x', ts: 1 }], now });
    expect(r).toBe(null);
  });
  it('нет совпадающего пропуска → null', () => {
    expect(consumeDecision({ action: 'bash:y', grants: [{ action: 'bash:x', ts: 1 }], consumed: [], now })).toBe(null);
  });
});
```

- [ ] **Step 5.2 — Прогнать FAIL.**

- [ ] **Step 5.3 — Реализовать `tools/floor-escape-consume.mjs` (чистое ядро)**
```js
#!/usr/bin/env node
/** floor-escape-consume (М6) — чистое ядро одноразового погашения. */
import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs';

/** Если исполненное действие совпало со свежим непогашенным пропуском — вернуть отметку, иначе null. */
export function consumeDecision({ action, grants, consumed, now = Date.now() }) {
  if (!escapeGrantOpen(action, grants, consumed, now)) return null;
  const g = grants.find((x) => x && x.action === action
    && !(consumed || []).some((c) => c.action === x.action && c.ts === x.ts));
  return g ? { action: g.action, ts: g.ts } : null;
}

export { canonicalAction }; // реэкспорт для обёртки
```

- [ ] **Step 5.4 — Прогнать PASS.**

- [ ] **Step 5.5 — Обёртка `tools/enforce-floor-escape-consume.mjs` (PostToolUse, fail-open)**
```js
#!/usr/bin/env node
import { appendFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { readStdin, parseEventJson } from './enforce-hook-helpers.mjs';
import { loadFloorEscapes, loadConsumed, canonicalAction } from './escape-grant.mjs';
import { consumeDecision } from './floor-escape-consume.mjs';

async function main() {
  try {
    const ev = parseEventJson(await readStdin());
    const sess = (ev && ev.session_id) || 'unknown';
    const action = canonicalAction(ev.tool_name, ev.tool_input || {});
    const mark = consumeDecision({ action, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess) });
    if (mark) {
      const path = join(homedir(), '.claude', 'runtime', `floor-escape-consumed-${sess}.jsonl`);
      try { mkdirSync(dirname(path), { recursive: true }); } catch {}
      try { appendFileSync(path, JSON.stringify(mark) + '\n'); } catch {}
    }
  } catch { /* fail-open: никогда не бросаем из PostToolUse */ }
  process.exit(0);
}
const isCli = (process.argv[1] || '').replace(/\\\\/g, '/').endsWith('/enforce-floor-escape-consume.mjs');
if (isCli) main();
```
(Обёртка — тонкая I/O, юнит-логика покрыта Пакетом 5.1-5.4; smoke — в Пакете 8 интеграции.)

- [ ] **Step 5.6 — Commit** (`feat(m6): floor-escape-consume — одноразовое погашение пропуска (PostToolUse)`).

---

## Пакет 6 — Egress-escape (`enforce-mcp-classification.mjs`)

**Files:**
- Modify: `tools/enforce-mcp-classification.mjs`
- Test: `tools/enforce-mcp-classification.test.mjs` (существующий)

- [ ] **Step 6.1 — audit-context-building** по `enforce-mcp-classification.mjs` (`decide`, `scanEgress`, `classifyMcpTool`).

- [ ] **Step 6.2 — RED: тест в существующий тест-файл**
```js
import { decide } from './enforce-mcp-classification.mjs';
it('egress-блок снимается совпавшим floor_escape', () => {
  const input = { url: 'http://1.2.3.4/x' }; // IP-литерал → egress block
  const action = `mcp:mcp__x__send:${require('./askuser-answer-parser.mjs').normalizeCommand(JSON.stringify(input))}`;
  const r = decide({ toolName: 'mcp__x__send', toolInput: input,
    escapeGrants: [{ action, ts: Date.now() }], escapeConsumed: [] });
  expect(r.block).toBe(false);
});
it('egress-блок без пропуска остаётся', () => {
  const r = decide({ toolName: 'mcp__x__send', toolInput: { url: 'http://1.2.3.4/x' }, escapeGrants: [], escapeConsumed: [] });
  expect(r.block).toBe(true);
});
```

- [ ] **Step 6.3 — Прогнать FAIL.**

- [ ] **Step 6.4 — Реализовать: `decide` принимает escape, проверяет ПОСЛЕ egress.block**
```js
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';

export function decide({ toolName, toolInput, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
  const name = String(toolName || '');
  if (!name.startsWith('mcp__')) return { block: false, reason: null };
  const verdict = classifyMcpTool(name, toolInput || {}, {});
  const escaped = () => escapeGrantOpen(canonicalAction(name, toolInput || {}), escapeGrants, escapeConsumed, now);
  if (verdict && (verdict.decision === 'block' || verdict.decision === 'ask')) {
    if (escaped()) return { block: false, reason: 'mcp: разрешено аварийным выходом (floor_escape)' };
    return { block: true, reason: verdict.reason || `${name} requires approval (decision=${verdict.decision})` };
  }
  const egress = scanEgress(toolInput || {});
  if (egress.block) {
    if (escaped()) return { block: false, reason: 'egress: разрешено аварийным выходом (floor_escape)' };
    return { block: true, reason: `egress: ${egress.reason}` };
  }
  return { block: false, reason: null };
}
// main(): const sess = event.session_id; передать loadFloorEscapes(sess)/loadConsumed(sess) в decide.
```

- [ ] **Step 6.5 — Прогнать PASS.**

- [ ] **Step 6.6 — Commit** (`feat(m6): egress-escape — снятие egress-блока совпавшим floor_escape`).

---

## Пакет 7 — Авто-снимок: ядро `snapshot-decide.mjs`

**Files:**
- Create: `tools/snapshot-decide.mjs`
- Test: `tools/snapshot-decide.test.mjs`

- [ ] **Step 7.1 — RED: `tools/snapshot-decide.test.mjs`**
```js
import { describe, it, expect } from 'vitest';
import { snapshotNeeded, resolveGitState } from './snapshot-decide.mjs';
describe('snapshot-decide snapshotNeeded', () => {
  it('Bash floor (reset --hard) → нужен', () => {
    expect(snapshotNeeded('Bash', { command: 'git reset --hard' })).toBe(true);
  });
  it('обычный Bash → не нужен', () => {
    expect(snapshotNeeded('Bash', { command: 'git status' })).toBe(false);
  });
  it('Write → не нужен', () => {
    expect(snapshotNeeded('Write', { file_path: '/a/b' })).toBe(false);
  });
});
describe('snapshot-decide resolveGitState (чистое-дерево vs ошибка)', () => {
  it('stash create дал sha → ref=sha, ok', () => {
    expect(resolveGitState({ stashOut: 'abc123\n', headOut: 'head1\n', error: false })).toEqual({ ok: true, ref: 'abc123' });
  });
  it('чистое дерево (stash пуст) → ref=HEAD, ok (успех, не ошибка)', () => {
    expect(resolveGitState({ stashOut: '   \n', headOut: 'head1\n', error: false })).toEqual({ ok: true, ref: 'head1' });
  });
  it('ошибка git → ok:false (fail-close)', () => {
    expect(resolveGitState({ stashOut: '', headOut: '', error: true })).toEqual({ ok: false, ref: null });
  });
});
```

- [ ] **Step 7.2 — Прогнать FAIL.**

- [ ] **Step 7.3 — Реализовать `tools/snapshot-decide.mjs`**
```js
#!/usr/bin/env node
/** snapshot-decide (М6, Блок 2) — чистое ядро авто-снимка. */
import { classifyDestructive } from './classify-destructive.mjs';
import { bashIsFloor } from './floor-decide.mjs';

/** Нужен ли снимок: только Bash-floor (reset --hard / rm -rf и т.п.). */
export function snapshotNeeded(toolName, toolInput) {
  if (toolName !== 'Bash') return false;
  return bashIsFloor((toolInput && toolInput.command) || '');
}

/**
 * Разобрать результат git-захвата. Чистое дерево (stash пуст) → ref=HEAD = УСПЕХ.
 * Реальная ошибка git → ok:false (fail-close). Различение F-S2.
 */
export function resolveGitState({ stashOut, headOut, error }) {
  if (error) return { ok: false, ref: null };
  const stash = String(stashOut || '').trim();
  if (stash) return { ok: true, ref: stash };
  const head = String(headOut || '').trim();
  if (head) return { ok: true, ref: head };
  return { ok: false, ref: null };
}
```
(NB: `bashIsFloor` уже экспортирован из `floor-decide.mjs` — переиспользуем, не дублируем regex.)

- [ ] **Step 7.4 — Прогнать PASS.**

- [ ] **Step 7.5 — Commit** (`feat(m6): snapshot-decide — триггер снимка + чистое-дерево vs ошибка`).

---

## Пакет 8 — Авто-снимок: обёртка `enforce-snapshot.mjs` (PreToolUse)

**Files:**
- Create: `tools/enforce-snapshot.mjs`
- Test: `tools/enforce-snapshot.test.mjs`

- [ ] **Step 8.1 — RED: тест чистой логики решения (инъекция git-исполнителя + writer)**

`tools/enforce-snapshot.test.mjs`:
```js
import { describe, it, expect } from 'vitest';
import { snapshotDecision } from './enforce-snapshot.mjs';
describe('enforce-snapshot snapshotDecision', () => {
  const ev = { tool_name: 'Bash', tool_input: { command: 'git reset --hard' }, session_id: 's' };
  it('успешный снимок → block:false + записан restore-point', () => {
    const writes = [];
    const r = snapshotDecision(ev, {
      gitImpl: () => ({ stashOut: 'sha9', headOut: 'h', error: false }),
      writeImpl: (rec) => writes.push(rec), idImpl: () => 'id1', now: 5,
    });
    expect(r.block).toBe(false);
    expect(writes[0]).toMatchObject({ snapshot_ref: 'sha9', action: 'bash:git reset --hard' });
  });
  it('ошибка git на разрушительном → block:true (fail-close), без записи', () => {
    const writes = [];
    const r = snapshotDecision(ev, { gitImpl: () => ({ error: true }), writeImpl: (rec) => writes.push(rec), idImpl: () => 'id1', now: 5 });
    expect(r.block).toBe(true);
    expect(writes.length).toBe(0);
  });
  it('неразрушительное → block:false, снимка нет', () => {
    const writes = [];
    const r = snapshotDecision({ tool_name: 'Bash', tool_input: { command: 'git status' }, session_id: 's' },
      { gitImpl: () => { throw new Error('git не должен зваться'); }, writeImpl: (rec) => writes.push(rec), idImpl: () => 'x', now: 5 });
    expect(r.block).toBe(false);
    expect(writes.length).toBe(0);
  });
});
```

- [ ] **Step 8.2 — Прогнать FAIL.**

- [ ] **Step 8.3 — Реализовать `tools/enforce-snapshot.mjs`**
```js
#!/usr/bin/env node
/**
 * enforce-snapshot (М6, Блок 2) — PreToolUse после enforce-floor. Перед разрушительным
 * (snapshotNeeded) делает git-точку возврата (git stash create + update-ref
 * refs/floor-snapshots/<id>), пишет restore-points.jsonl. Чистое дерево → ref=HEAD (успех);
 * реальная ошибка git → fail-CLOSE (block). Пишет в runtime как процесс-хук (легитимно).
 */
import { appendFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
import { snapshotNeeded, resolveGitState } from './snapshot-decide.mjs';
import { canonicalAction } from './escape-grant.mjs';

function defaultGit() {
  try {
    const stashOut = execFileSync('git', ['stash', 'create'], { encoding: 'utf-8' });
    const headOut = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' });
    return { stashOut, headOut, error: false };
  } catch { return { stashOut: '', headOut: '', error: true }; }
}
function defaultWrite(rec) {
  const path = join(homedir(), '.claude', 'runtime', 'restore-points.jsonl');
  try { mkdirSync(dirname(path), { recursive: true }); } catch {}
  try { appendFileSync(path, JSON.stringify(rec) + '\n'); } catch {}
}

/** Чистое решение (инъекция git/write/id/now для тестов). */
export function snapshotDecision(event, { gitImpl = defaultGit, writeImpl = defaultWrite, idImpl, now = Date.now() } = {}) {
  if (!snapshotNeeded(event.tool_name, event.tool_input || {})) return { block: false };
  const st = resolveGitState(gitImpl());
  if (!st.ok) return { block: true, message: '[snapshot] не смог сделать точку возврата (ошибка git) — действие остановлено' };
  const id = (idImpl ? idImpl() : String(now)) + '';
  // закрепить ref (вне теста): execFileSync('git',['update-ref',`refs/floor-snapshots/${id}`, st.ref])
  if (gitImpl === defaultGit) { try { execFileSync('git', ['update-ref', `refs/floor-snapshots/${id}`, st.ref]); } catch { return { block: true, message: '[snapshot] update-ref не удался — fail-close' }; } }
  writeImpl({ ts: now, action: canonicalAction(event.tool_name, event.tool_input || {}), head: st.ref,
    snapshot_ref: `refs/floor-snapshots/${id}`, restore_command: `git checkout refs/floor-snapshots/${id} -- .` });
  return { block: false };
}

async function main() {
  try {
    const ev = parseEventJson(await readStdin());
    const r = snapshotDecision(ev);
    exitDecision({ block: r.block, message: r.block ? r.message : undefined });
  } catch { exitDecision({ block: false }); } // снимок — страховка; своя инфра-ошибка не должна клинить (но git-ошибка выше = block)
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```
(NB: `main`-catch fail-open — на инфра-сбой парсинга; РЕАЛЬНАЯ git-ошибка ловится в `snapshotDecision` → block. Разделение осознанное.)

- [ ] **Step 8.4 — Прогнать PASS.**

- [ ] **Step 8.5 — Commit** (`feat(m6): enforce-snapshot — git-точка возврата перед разрушительным (fail-close)`).

---

## Пакет 9 — Интеграционные тесты + регрессия

**Files:**
- Create: `tools/m6-escape.test.mjs`, `tools/m6-snapshot.test.mjs`

- [ ] **Step 9.1 — `tools/m6-escape.test.mjs` (сквозные инварианты)**
```js
import { describe, it, expect } from 'vitest';
import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs';
import { floorDecide } from './floor-decide.mjs';
import { toFloorEscapeRecord } from './askuser-answer-parser.mjs';

describe('M6 escape — сквозной binding', () => {
  const now = 1000, ID = (x) => x;
  it('показанное владельцу действие == живое → открыт; подмена → закрыт', () => {
    // парсер строит грант из показанного варианта
    const rec = toFloorEscapeRecord('да FLOOR-ESCAPE: bash:git push --force', { nowMs: now - 5 });
    const grants = [{ action: rec.action, ts: rec.ts }];
    // живое совпадает
    expect(floorDecide({ toolUse: { name: 'Bash', input: { command: 'git push --force' } },
      escapeGrants: grants, escapeConsumed: [], now, normalizeImpl: ID }).block).toBe(false);
    // живое ДРУГОЕ (подмена) → блок
    expect(floorDecide({ toolUse: { name: 'Bash', input: { command: 'git reset --hard' } },
      escapeGrants: grants, escapeConsumed: [], now, normalizeImpl: ID }).block).toBe(true);
  });
  it('F-S1: floor_escape и approve_git_operation не пересекаются (разные type/reader)', () => {
    // approve_git_operation-запись не имеет поля action → escapeGrantOpen её не видит
    expect(escapeGrantOpen('bash:git push --force', [{ command: 'git push --force', ts: now }], [], now)).toBe(false);
  });
});
```

- [ ] **Step 9.2 — `tools/m6-snapshot.test.mjs`**
```js
import { describe, it, expect } from 'vitest';
import { snapshotNeeded } from './snapshot-decide.mjs';
import { snapshotDecision } from './enforce-snapshot.mjs';
describe('M6 snapshot — интеграция', () => {
  it('escaped reset --hard → снимок делается до выполнения', () => {
    const writes = [];
    const r = snapshotDecision({ tool_name: 'Bash', tool_input: { command: 'git reset --hard' }, session_id: 's' },
      { gitImpl: () => ({ stashOut: 'sha', headOut: 'h', error: false }), writeImpl: (x) => writes.push(x), idImpl: () => 'i', now: 1 });
    expect(r.block).toBe(false); expect(writes.length).toBe(1);
  });
  it('snapshotNeeded только на Bash-floor', () => {
    expect(snapshotNeeded('Bash', { command: 'rm -rf x' })).toBe(true);
    expect(snapshotNeeded('Write', { file_path: 'a' })).toBe(false);
  });
});
```

- [ ] **Step 9.3 — Прогнать оба + ПОЛНУЮ регрессию**

Run (полная, без фильтра): `node "<wt>\app\node_modules\vitest\vitest.mjs" run --root "<wt>\app" --config "<wt>\app\vitest.config.tools.mjs" --reporter dot`
Expected: **≥ 2789 passed + 2 skip** (новые тесты добавляют сверху; 0 регрессий). Если упало — `superpowers:systematic-debugging` (не угадывать).

- [ ] **Step 9.4 — Commit** (`test(m6): сквозные инварианты escape + snapshot; регрессия зелёная`).

---

## Завершение (после всех пакетов)

- [ ] `superpowers:requesting-code-review` инлайн (самопроверка — суб-агент запрещён): пройтись по диффу против спека + 5 критериев аудита.
- [ ] `superpowers:verification-before-completion`: свежий полный прогон, привести вывод. Без него «готово» НЕ говорить.
- [ ] Память: topic-файл `project_router_mentor_machine_6_done.md` (кодовая фраза «роутер-наставник») + индекс MEMORY.md. Запись памяти = два охранника (`claude-md-management` активен в том же turn + `coverage: direct:memory-sync`).
- [ ] Handoff-заметка для активации владельцем (НЕ код): регистрация в `.claude/settings.json` — `enforce-snapshot` (PreToolUse, ПОСЛЕ enforce-floor) + `enforce-floor-escape-consume` (PostToolUse); `enforce-floor`/`enforce-askuser-answer-parser`/`enforce-mcp-classification`/`enforce-supreme-gate` уже зарегистрированы (их правки активны после рестарта). Сперва тихий режим, затем hard-block.
- [ ] **Пуш — ТОЛЬКО по слову владельца «пуш»** (`superpowers:finishing-a-development-branch`).

---

## Self-review (writing-plans)

**Покрытие спека:** §3 модули → Пакеты 1-9 + 4b (escape-grant П1; floor-decide/enforce-floor П4; askuser-answer-parser П2; enforce-askuser-answer-parser П3; **enforce-supreme-gate П4b (G-1 α)**; enforce-mcp-classification П6; floor-escape-consume П5; snapshot-decide П7; enforce-snapshot П8). One-shot (§4 F-S1) → П5. 3 локуса (§4) → П4 (Bash+Write) + П6 (egress); **+ сквозь стену М2 → П4b (G-1 α — иначе escape no-op end-to-end)**. binding (F3) → canonicalAction П1, тест П9.1; **G-5** точный токен в блок-сообщении пола (П4 Step 4.4). **G-6** запрет `override` в floor-файлах (П4). **G-2** переписан блок двери (П4 Step 4.4b). F-S2 → П7 resolveGitState. F4 (не-git одобряемы) → floor_escape (не git-only). fail-close (§5) → П8. **G-8** канон egress детерминирован (П1/спек §4).
**Плейсхолдеры:** нет (код во всех шагах).
**Типы:** `canonicalAction`/`escapeGrantOpen`/`loadFloorEscapes`/`loadConsumed`/`consumeDecision`/`snapshotNeeded`/`resolveGitState`/`snapshotDecision` — имена согласованы между пакетами. Грант-форма `{action, ts}` едина; consumed-форма `{action, ts}` едина; floor_escape-запись `{type:'floor_escape', action, ts}` едина.
**Новый модуль вне спека:** `floor-escape-consume` (П5) — добавлен для корректной одноразовости; согласовать на ревью плана.
