From 8d0ef388317fffd3d4aa992cb4512d5aa7d86f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 7 Jun 2026 17:57:01 +0300 Subject: [PATCH] =?UTF-8?q?docs(router-mentor):=20M6=20implementation=20pl?= =?UTF-8?q?an=20=E2=80=94=20escape=20+=20auto-snapshot=20(TDD=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D1=8B=201-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit План реализации Машины 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) --- .../2026-06-07-router-mentor-machine-6.md | 781 ++++++++++++++++++ 1 file changed, 781 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md diff --git a/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md b/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md new file mode 100644 index 00000000..eed55e7d --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-router-mentor-machine-6.md @@ -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 (``):** `c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo` + +**Прогон тестов (ОДНА абсолютная команда, без cd/&&):** +``` +node "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\app\vitest.config.tools.mjs" <фильтр> --reporter dot +``` +`<фильтр>` = путь тест-файла, напр. `tools/m6-escape.test.mjs`. Полный прогон — без фильтра; планка ≥ **2789 passed + 2 skip**. + +**Коммит (через PowerShell, без переменных, литеральные пути; Bash-гейт PowerShell не матчит):** +``` +git -C "" add <файлы> +git -C "" commit -F "\.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:', () => { + expect(canonicalAction('Bash', { command: 'git push --force' }, { normalizeImpl: ID })) + .toBe('bash:git push --force'); + }); + it('Write → write:', () => { + expect(canonicalAction('Write', { file_path: '/a/.env' }, { normalizeImpl: ID })) + .toBe('write:/a/.env'); + }); + it('MCP → mcp::', () => { + 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 "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\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 "" add tools/escape-grant.mjs tools/escape-grant.test.mjs +git -C "" commit -F "\.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: `). + +- [ ] **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/), пишет 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 "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\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) — добавлен для корректной одноразовости; согласовать на ревью плана.