docs(router-mentor): M6 implementation plan — escape + auto-snapshot (TDD пакеты 1-9)

План реализации Машины 6 по спеку 2026-06-07-router-mentor-machine-6-design.md.
9 пакетов, bite-sized TDD (RED→GREEN→commit), весь код в шагах, конвенции
(vitest абс-команда / commit через PowerShell / TDD-гейт / audit-context перед патчами).

Пакеты: 1 escape-grant ядро · 2 toFloorEscapeRecord · 3 писатель floor_escape ·
4 пол escape во всех ветках · 5 floor-escape-consume (one-shot, PostToolUse) ·
6 egress-escape · 7 snapshot-decide · 8 enforce-snapshot · 9 интеграция+регрессия.

NB: Пакет 5 вводит модуль floor-escape-consume, которого нет в инвентаре §9 спека —
операционализация одноразовости «погашение после исполнения»; отмечено в self-review,
к согласованию на ревью плана. Планка регрессии ≥ 2789 passed + 2 skip.

Только план-артефакт, кода нет. Без push (commit-not-push).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-07 17:57:01 +03:00
parent 1ba61b2603
commit 8d0ef38831
@@ -0,0 +1,781 @@
# Машина 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` и его тесты в `floor-decide.test.mjs` (заменены escape-тестами). Сообщение в блоке оставить понятным владельцу (контроллер по нему строит AskUser с токеном `FLOOR-ESCAPE: <canonicalAction>`).
- [ ] **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`).
---
## Пакет 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` уже зарегистрированы (их правки активны после рестарта). Сперва тихий режим, затем hard-block.
- [ ] **Пуш — ТОЛЬКО по слову владельца «пуш»** (`superpowers:finishing-a-development-branch`).
---
## Self-review (writing-plans)
**Покрытие спека:** §3 модули → Пакеты 1-8 (escape-grant П1; floor-decide/enforce-floor П4; askuser-answer-parser П2; enforce-askuser-answer-parser П3; enforce-mcp-classification П6; snapshot-decide П7; enforce-snapshot П8). One-shot (§4 F-S1) → П5 (новый floor-escape-consume — операционализация «погашения после применения»; **отклонение от инвентаря §9 спека**, оформлено как PostToolUse-консьюмер — отметить владельцу). 3 локуса (§4) → П4 (Bash+Write) + П6 (egress). binding (F3) → canonicalAction П1, тест П9.1. F-S2 → П7 resolveGitState. F4 (не-git одобряемы) → покрыто floor_escape (не git-only) — добавить явный тест в П2/П9. fail-close (§5) → П8.
**Плейсхолдеры:** нет (код во всех шагах).
**Типы:** `canonicalAction`/`escapeGrantOpen`/`loadFloorEscapes`/`loadConsumed`/`consumeDecision`/`snapshotNeeded`/`resolveGitState`/`snapshotDecision` — имена согласованы между пакетами. Грант-форма `{action, ts}` едина; consumed-форма `{action, ts}` едина; floor_escape-запись `{type:'floor_escape', action, ts}` едина.
**Новый модуль вне спека:** `floor-escape-consume` (П5) — добавлен для корректной одноразовости; согласовать на ревью плана.