# Машина 2 — Замок плана + Верховный хук — детальный план реализации

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** заморозить одобренный план хеш-печатью и поставить главную стену — ни одно действие любым инструментом не исполняется без шага замороженного плана (default-deny, матчер = `*`), при семенах-исключениях для запуска.

**Architecture:** чистое ядро (`decide`) + тонкий CLI-хук (`main`) — паттерн проекта. Стоит на Машине 1: замороженный план — это подписанная расписка (`receipt-sign`, ключ из `receipt-key-config`), действия пишутся в Журнал S1 (`action-journal`). Совпадение «действие ↔ шаг плана» — **детерминированное** (вид операции + нормализованный объект через `pathNormalize`), без LLM и без зашитых списков (требование портативности). Default-deny на ВСЕ инструменты; семена `writing-plans`/`EnterPlanMode`/`AskUserQuestion` пропускаются (иначе стену нельзя загрузить — D12/D13).

> **СТАТУС РЕВЬЮ 2026-06-04:** ВСЕ решения **A, B, C (C-1…C-14), L-серия, D, E, F, G** — ✅ ЗАКРЫТЫ владельцем (A–G + L финальны). **Идёт fresh-eyes ревизия плана (2026-06-04):** findings 1–5 (закрытая дверь) **починены**; findings 6–7 (D33/D29) **разрешены** (D29 поглощена слоем общения+закрытой дверью; D33 → два режима, смысл в М3); **finding 8 растворён** призмой владельца (нет болтовни → дисциплина всегда на, триггер не нужен); **finding 9 закрыт** (зелёный проход = «нет долговременного/исходящего эффекта» + условия А/Б/В). **Все findings 1–9 + мелочи + флажок дизайн-спеки закрыты — fresh-eyes ревизия завершена.** Готово к сборке по слову владельца. Исполнение — `subagent-driven-development`. Машина 1 — ✅ собрана (фундамент готов). Ниже добавлен слой **C/L-надстройки**: план расщеплён на ДВА (разговорный + реализационный), у судьи ДВЕ печати/гейта, добавлены закрытая дверь, контракты скилов и апелляция. Существующие Tasks 1–9 — это стена **реализационного** плана (остаются верны); C/L-надстройка добавляет режим разговорного плана + вторую печать + резолвер закрытой двери (новые **Tasks 10–12**, дефолты `[РЕШЕНИЕ H–K]` на ревью).

## 🚨🔴 СТОП — МЕЖМАШИННЫЕ КОНТРАКТЫ (БЕЗ НИХ НАШИ ЗАКРЫТИЯ = ДЫРЫ ДИСЦИПЛИНЫ)

> **ЧИТАТЬ ПЕРЕД СБОРКОЙ МАШИН 3/4/5. НЕ ПРОПУСКАТЬ.** Ревизия 2026-06-04 расслабила стену в одном месте (зелёный проход) и сместила часть нагрузки на соседние машины. **Три закрытия держатся НЕ здесь — а в М4/М5.** Соберёшь те машины слабо → наши «закрытия» становятся РЕАЛЬНЫМИ дырами. Каждый контракт несёт «что будет, если пропустить».

- 🔴 **K1 → Машина 4 (судья, гейт-1) — печать только по одобрению + артефакт всегда владельцу.** `freezeArtifact`/`freezePlan` вызывает ТОЛЬКО процесс одобрения гейта-1 (HMAC-ключ контроллеру недоступен, М1); артефакт ВСЕГДА доводится до владельца (это замена снятой сверки D29). **Пропустишь → я сам себе печатаю артефакт/план; план не привязан к настоящей воле владельца.**
- 🔴 **K2 → Машина 4 (судья, A2) — покрытие навыком по ЖУРНАЛУ, не по словам.** «Взял ли я навык» сверяется по ФАКТИЧЕСКОМУ журналу вызовов (М1), НЕ по тексту TodoWrite / coverage-ярлыка. **Пропустишь → зелёный проход TodoWrite превращается в путь ФЕЙКОВОГО покрытия навыком (напишу „покрыто навыком X“, не вызвав его).**
- 🔴 **K3 → Машина 5 (пол, `classifyBashCommand`) — консервативный readonly.** Команда помечается `readonly` ТОЛЬКО если заведомо смотрящая; редирект `>`/`>>`/`|`-в-писатель, `git config`/`gc`/`push`/`commit`, `tee`, `$(...)`, и ЛЮБАЯ неуверенность → НЕ readonly. **Пропустишь → зелёный проход пропустит МУТАЦИЮ мимо плана (напр. `git log > evil.sh`).**
- 🔴 **K4 → сборка М2/М3 (МИНА, не чинить на автомате).** В разговорном режиме свободный Write закрыт, а навыки `brainstorming`/`writing-plans` сами пишут файлы (спеку/план). Решить ОСОЗНАННО: артефакт/спека материализуются ЧЕРЕЗ КАНАЛ ОДОБРЕНИЯ (семя выдаёт предложение-текст), а НЕ широким Write. **«Починишь» широким Write → откроешь ровно тот обход — freestyle-артефакт мимо думанья.**
- 🔴 **K6 → Машина 4 (анти-откат, добавлен 2026-06-05 аудитом).** Замороженный план/артефакт + указатель шага М2 и журнал М1 ПОДПИСАНЫ, но старая подпись валидна → подмена на раннюю валидную пару файлов = откат (спрятать прошлое действие / отмотать шаг). M2 этого НЕ ловит (печать цела, просто старая) и сознательно НЕ строит зарубку у себя (зарубка в runtime откатится вместе с журналом = призрак). Монотонную зарубку (high-water-mark по `seq`/шагу) ВНЕ откатываемого набора держит судья (K6 в §Машина 4). **Пропустишь при сборке М4 → откат-сокрытие прошлого свободен, как только пробит пол `runtime-write-deny`.**

> Источник — fresh-eyes ревизия 2026-06-04 (раздел Self-Review ниже, условия А/Б/В finding 9 + контракт «кто печатает» [РЕШЕНИЕ I]).

---

**Два плана (C-7) — рамка Машины 2:**
- **Разговорный план** (думательный): цель + только семена (думать/спрашивать) + обязательный артефакт и условие «готово»; последовательность эмерджентна; матча НЕТ (плана-стройки ещё нет); защита окна — запрет инструментов реализации + всегда-включённый пол (секреты/runtime/force-push).
- **Реализационный план** (строящий): фиксирован, опечатан, строгий матч (A/B), каждый шаг ссылается на опечатанное решение артефакта (закрытая дверь C-5).
- **Две печати:** гейт-1 морозит АРТЕФАКТ (после разговора), гейт-2 морозит ПЛАН (перед стройкой). Судит — Машина 4; механизм заморозки (`plan-lock`) — здесь, переиспользуется для обоих.

> **[ПРИЗМА ВЛАДЕЛЬЦА 2026-06-04 — фиксировано]:** в этой среде **режима «болтовни» НЕТ** (захотел поговорить не по делу — идёшь в claude.ai). Значит ровно **ДВА режима** — разговорный (думанье) и реализационный (стройка); дисциплина включена **с первого сообщения**, off-состояния нет. Старый «спящий/болтовня» режим из D33 здесь **неприменим**. Следствие (finding 9): раз отдушины-болтовни нет, **зелёный проход обязан надёжно пускать обычную разведку, включая read-only Bash** (`git status`/`log`/`grep`) по классификации эффекта, иначе always-on дисциплина задушит повседневную работу.

**Tech Stack:** Node.js ESM, `node:crypto` (через `receipt-sign`), vitest. Зависимости из Машины 1: `tools/receipt-sign.mjs`, `tools/receipt-key-config.mjs`, `tools/action-journal.mjs`, `tools/path-normalization.mjs`, `tools/enforce-hook-helpers.mjs`.

**Источник истины:** мастер-карта `2026-06-03-router-mentor-master-roadmap.md` §«МАШИНА 2» (P15-a/b/d/e/f/g/h, замороженный план P15-f, верховный хук, авто-аудит дверей P15-b, сверка план↔след P25-d) + несущий №2 (замороженный план) + D12/D13 (семена) + Машина 1 (фундамент).

---

## ⚠️ Предварительно (прочитать ДО старта)

- **Контекст исполнения** — как в плане Машины 1 (worktree от `main`, тесты `npx vitest run --root app --config vitest.config.tools.mjs <фильтр>`, коммиты через терминал при гейте).
- **Известный блокер сборки-в-worktree:** `enforce-tdd-gate` не распознаёт правку существующего теста в worktree-подпути → блокирует правку существующих прод-файлов. **Новые файлы (Write) проходят.** Машина 2 — почти целиком новые файлы (`plan-lock.mjs`, `enforce-supreme-gate.mjs`, `door-coverage.mjs`), поэтому worktree-сборка возможна. Единственная правка существующего файла (Task 7 — askuser-парсер) делается под надзором ИЛИ против `main`.
- **ПРОЕКТНЫЕ РЕШЕНИЯ ПО УМОЛЧАНИЮ (помечены `[РЕШЕНИЕ]`)** приняты мной автономно из духа карты; владелец вправе пересмотреть на ревью. Каждое явно выделено.

---

## Ключевые проектные решения (зафиксированы здесь)

**[РЕШЕНИЕ A] Схема замороженного плана** — подписанная расписка:
```js
{
  plan_id: string,        // стабильный id (хеш содержания шагов при заморозке)
  artifact_id: string|null, // НА КАКОЙ опечатанный артефакт опирается план (привязка к версии — закрытая дверь C-5; null для планов без артефакта)
  frozen_at: number,      // ms
  steps: [
    { n: number,          // 1-based номер шага
      op: string,         // вид операции: 'Edit'|'Write'|'Bash'|'Skill'|'Task'|...
      object: string,     // объект: путь файла / команда / имя скила
      ref: string|undefined, // ссылка на опечатанное решение артефакта (§X) — закрытая дверь C-5; нет → простой шаг
      intent: string }    // человекочитаемое намерение (для владельца, не для матча)
  ],
  sig: string             // HMAC (Машина 1) — это и есть хеш-печать P15-f (покрывает plan_id+artifact_id+steps)
}
```
Правка плана = новый `sig` (печать сменилась → видно владельцу). Замороженный план пишется в `~/.claude/runtime/frozen-plan-<sess>.json` через Node fs (под защитой runtime-write-deny от Write-tool). **`artifact_id`** связывает план с КОНКРЕТНОЙ версией артефакта: перепечатали артефакт → его id сменится → стена увидит рассинхрон (Task 12).

**[РЕШЕНИЕ B] Детерминированный матч «действие ↔ шаг»** (P15-a + P15-e, БЕЗ LLM): шаг матчится, если `op` совпадает с инструментом действия И `object` совпадает с объектом действия после `pathNormalize` (для файловых) либо нормализации команды (для Bash). «Совпадение по операции, не только объекту» (P15-e) = обе оси обязаны совпасть. Порядок шагов соблюдается через указатель `step_ptr` (как `chain_step` в существующем gate).

**[РЕШЕНИЕ C] Семена-исключения** (D12/D13): инструменты `Skill` с именем `writing-plans`/`brainstorming`/`discovery-interview`, `EnterPlanMode`, `AskUserQuestion` — пропускаются ВСЕГДА, даже без плана (иначе план нечем создать). Список «семян» — НЕ зашитая «карта критического», а узкий технический allowlist загрузки; вынесен в одну константу с комментарием-обоснованием.

**[РЕШЕНИЕ D — ✅ закрыто 2026-06-04] Default-deny матчер = `*` + зелёный проход по способности.** Хук видит ВСЕ инструменты (регистрация matcher `*` в settings.json — owner-шаг); `decide()` toolName-агностичен, судит по сути (op+object) — урок F1 (PowerShell мимо Bash). **Закреплённое условие (обязательное правило, не устная оговорка):** у стены есть тупо-механический «зелёный проход» (always-allow), строго по СПОСОБНОСТИ инструмента:
- **(а) проверенные семена** (`isSeed`, H4: делают только своё узкое дело — написать план / спросить; попыталось больше → не семя → блок);
- **(б) безобидно-смотрящее по способности** (`isObserveOnly`, правило finding 9 «нет долговременного И нет исходящего эффекта»): Read/Grep/Glob + **read-only Bash** (по `classifyBashCommand`, консервативно к редиректам — условие А) + **TodoWrite**/эфемерная мета; WebFetch/WebSearch/внешний MCP — НЕ сюда (исходящий канал).

**Никогда** — целый мутирующий инструмент (Bash/Write/Edit/MultiEdit/Task/…): различие «смотрит / меняет» — по СУТИ, не по имени (D себе не противоречит). Зелёный проход снимает ТОЛЬКО дисциплину (требование «шаг плана») и **НЕ** снимает защитный пол (секреты/runtime/force-push/разрушительное — non-escapable; они ловят опасное у read/observe, напр. чтение секрета режет read-path-deny). Сам список зелёного прохода — под авто-аудитом дверей (Task 7 / пункт F): мутирующий инструмент или забытое расширение в нём → флаг. **Реализация:** `isObserveOnly(toolUse)` рядом с `isSeed` (Task 4), green-pass в `decide()`/`decideMode()` перед проверкой шага (Task 5/10).

**[РЕШЕНИЕ E — ✅ закрыто 2026-06-04] Указатель шага = ЛИНЕЙНЫЙ счётчик сейчас.** `step_ptr` (число) — для прямого плана достаточно. «Волны» (дерево указателей, ветвление под-планов D6/OQ1) — ОТЛОЖЕНО в Машину 3 (там роутер собирает/разбивает планы); **помечено в мастер-карте, не забыть**. До Машины 3 планы прямые → линейного хватает. **[РЕШЕНИЕ F — ✅ закрыто 2026-06-04] Авто-аудит дверей** (Task 7): перечисляет ВСЕ инструменты, флагует непокрытое стеной — «забытый канал» как КЛАСС. **Дополнено к D:** аудит также флагует **мутирующий инструмент, заползший в «зелёный проход»** (страховка к закреплённому условию D — зелёный проход только семена + только-смотрящие). **[РЕШЕНИЕ G — ✅ закрыто 2026-06-04]** сверка план↔журнал (Task 8): каждое журнальное действие обязано иметь шаг плана; «сирота» → флаг владельцу. Ревизия постфактум: стена не пускает на входе, G ловит просочившееся мимо ворот.

### C/L-надстройка — зафиксированные решения (A/B/C/L закрыты 2026-06-04)

> Дефолты `[РЕШЕНИЕ H–K]` приняты из духа закрытых C-1…C-14 + L-серии; владелец вправе пересмотреть при ревью соответствующих задач (10–12). Помечены как автономные дефолты, по образцу A–G.

**[РЕШЕНИЕ H] Режим разговорного плана** (C-7, M2-1): отдельный режим стены — пропускает только семена (думать/спрашивать), любой инструмент реализации блокируется, матча нет (плана-стройки ещё нет). Условие выхода — заполненный артефакт (бриф/спека) + его заморозка (гейт-1). Default-deny здесь = «всё кроме семян», вместо «всё кроме шага плана». **Бэкстоп:** реализационный план не примем без валидного замороженного артефакта (ничего не строится без управляемого разговора позади).
- **Условие В (finding 9, закрытие обхода через freestyle-артефакт):** в разговорном режиме **свободный Write/Edit ЗАКРЫТ** (он мутирующий → стена). Артефакт **не пишется свободной правкой**, а **материализуется и печатается только каналом одобрения гейта-1** (из подтверждённого содержания — как `askuser-parser` пишет расписку; см. контракт «кто печатает» в [РЕШЕНИЕ I]). Зелёный проход (observe/TodoWrite/readonly-Bash) артефакт материализовать НЕ может. Иначе: либо слой неработоспособен, либо «починка» свободным Write открыла бы обход — написал спеку от балды мимо думанья. Полноту артефакта проверяет гейт-1 (М4) против контрактов навыков.
  - **[СИНХРОН 2026-06-04 — условие В УТОЧНЕНО каноном Машины 3 (мина K4, Вариант А)]:** различаем ДВЕ вещи. **Печать (seal)** — по-прежнему ТОЛЬКО каналом одобрения гейта-1 (этот абзац верен). Но **читаемый `.md`-файл** (спека/план) канон M3 разрешает писать **напрямую через УЗКОЕ Write-исключение** decideMode: «активное думательное семя (`brainstorming`/`writing-plans`/`discovery-interview`, по журналу Skill) + канонический путь (`docs/superpowers/specs|plans/*.md`, `docs/discovery/*.md`)». Читаемый `.md` сам по себе безвреден (мусорную спеку отбракует гейт-1 → не запечатается → стройки нет). Формулировка «артефакт не пишется свободной правкой» относится к **печати и к произвольным путям**, НЕ к написанию `.md` думательным семенем. Это исключение реализуется в под-плане **3-D** (не в текущем коде M2 — до 3-D decideMode блокирует Write в разговоре). Канон: `2026-06-04-router-mentor-m3-shape-and-k4-design.md` §1.

**[РЕШЕНИЕ I] Вторая печать — заморозка артефакта** (C-10, M2-2): `plan-lock` переиспользуется — морозим не только реализационный план, но и артефакт разговорной фазы. Та же HMAC-печать; правка артефакта = новая печать (видно владельцу). Судит артефакт Машина 4 (гейт-1); Машина 2 даёт примитив заморозки + хранение опечатанного артефакта.
- **Контракт «кто печатает» (закреплено 2026-06-04, фикс ревью):** `freezeArtifact`/`freezePlan` вызывает **ТОЛЬКО процесс одобрения гейта** (через подписанный канал Машины 1 — как `askuser-answer-parser` пишет расписку из ответа владельца), а HMAC-ключ берётся из OS-keychain и контроллеру недоступен. Путь, где контроллер сам зовёт `freezeArtifact`/`freezePlan` и сам себе печатает, **не строится**. Сам триггер «одобрил → запечатал» — Машина 4 (гейт-1) + канал Машины 1; Машина 2 предоставляет примитив, загрузку и проверки, но НЕ самопечать. (Тот же принцип, что закрыл «сам-подписанные сигналы» в расписках.)

**[РЕШЕНИЕ J] Резолвер закрытой двери** (C-5, M2-5): шаг реализационного плана обязан нести ссылку на опечатанное решение артефакта (`ref: §X`); при матче действия стена дополнительно проверяет, что ссылка резолвится в опечатанном артефакте; не резолвится → блок. Нужно решение, которого нет → легальный ход только один: возврат в разговорный режим → пере-печать. «Спросил на ходу и продолжил» физически невозможен.

**[РЕШЕНИЕ K] Контракт скила + L-поля** (C-13, L-серия): схема контракта `{needs, produces, constraints, preview-form, defaults, key-decisions, acceptance-criteria}` — сквозной артефакт. Машина 2 его НЕ судит (это вход Машин 3/4), но: (а) шаг-семя «дописать/изменить контракт» проходит как думательный; (б) контракт защищён от само-ослабления (убрал нужду/критерий/расширил дефолт → блок + да владельца + diff — анти-отрава #9/#11) — реализуется в защитном слое (М5), здесь только отмечается стык.

**Что НЕ в Машине 2** (во избежание раздувания — уходит в М3/М4 per мастер-карта): читатель контрактов + машина охвата A/B/C/D + look-ahead + L-ядро роутера → Машина 3; судейство артефакта/плана на двух гейтах + апелляционный цикл C-12 → Машина 4; счётчик раундов апелляции — журнал М1. Машина 2 даёт лишь рамку двух режимов + две печати + закрытую дверь.

**Принцип «умный не баран»** (L-серия): стена/матч Машины 2 — тупо-механические (op+object, ссылка резолвится да/нет), их нельзя «уболтать». Свежее рассуждение — у роутера (М3) и судьи (М4), не у стены.

---

## Структура файлов

**Создаём:**
- `tools/plan-lock.mjs` — заморозка/верификация/загрузка плана + детерминированный матч шага. Чистое + персист (инъекция fs).
- `tools/enforce-supreme-gate.mjs` — верховный хук: `decide(ctx)` (allow/block по плану) + `main()` (matcher=*, fail-CLOSED).
- `tools/door-coverage.mjs` — авто-аудит покрытия дверей (P15-b): перечислить инструменты, флагнуть непокрытые.
- *(C/L-надстройка, Tasks 10–12)* расширения тех же файлов: `plan-lock.mjs` +заморозка артефакта (переиспользование) +резолв ссылки шага на опечатанный § (закрытая дверь); `enforce-supreme-gate.mjs` +разговорный режим (только семена) +проверка `ref` шага +бэкстоп «нет артефакта → план не примем». Контракт-схема `{needs/produces/constraints + L-поля}` — отдельный сквозной артефакт, читается Машинами 3/4 (здесь не строится).
- Тесты: `tools/plan-lock.test.mjs`, `tools/enforce-supreme-gate.test.mjs`, `tools/door-coverage.test.mjs`.

**Меняем (под надзором / против main):**
- `tools/askuser-answer-parser.mjs` — `buildFrozenPlanFromApproval` (расписка одобрения плана → замороженный план). [только если решим, что заморозку инициирует AskUser-канал; иначе заморозка — отдельный шаг]

**Порядок:** plan-lock (Task 1–3) → supreme-gate decide (Task 4–6) → door-coverage (Task 7) → plan↔journal reconcile (Task 8) → интеграция (Task 9) → **C/L-надстройка: разговорный режим+бэкстоп (Task 10) → вторая печать-артефакт (Task 11) → резолвер закрытой двери (Task 12)**.

---

## Task 1: Заморозка и верификация плана (`plan-lock.mjs` ч.1)

**Files:** Create `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающий тест**

```js
// tools/plan-lock.test.mjs
import { describe, it, expect } from 'vitest';
import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs';

const KEY = 'plan-lock-test-key';
const STEPS = [
  { n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'создать модуль' },
  { n: 2, op: 'Bash', object: 'npx vitest run foo', intent: 'прогнать тест' },
];

describe('freezePlan / verifyFrozenPlan', () => {
  it('freezePlan returns a signed plan with stable plan_id', () => {
    const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1000 });
    expect(p.plan_id).toMatch(/^[0-9a-f]{64}$/);
    expect(p.frozen_at).toBe(1000);
    expect(p.sig).toMatch(/^[0-9a-f]{64}$/);
    expect(p.steps).toHaveLength(2);
  });
  it('plan_id is deterministic over steps content', () => {
    expect(planId(STEPS)).toBe(planId(STEPS.map((s) => ({ ...s }))));
  });
  it('verifyFrozenPlan true for an intact signed plan', () => {
    const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
    expect(verifyFrozenPlan(p, KEY)).toBe(true);
  });
  it('verifyFrozenPlan false when a step is tampered (re-seal required)', () => {
    const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
    const tampered = { ...p, steps: [{ ...p.steps[0], object: 'tools/evil.mjs' }, p.steps[1]] };
    expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
  });
  it('verifyFrozenPlan false for unsigned / wrong key (no seal → fiction)', () => {
    const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
    expect(verifyFrozenPlan({ ...p, sig: undefined }, KEY)).toBe(false);
    expect(verifyFrozenPlan(p, 'other-key')).toBe(false);
  });
});
```

- [ ] **Step 2: RED** — `npx vitest run --root app --config vitest.config.tools.mjs plan-lock` → FAIL (import).

- [ ] **Step 3: Реализация**

```js
// tools/plan-lock.mjs
#!/usr/bin/env node
/**
 * plan-lock — Замок плана (несущий №2, P15-f): заморозка одобренного плана
 * хеш-печатью (HMAC из Машины 1). «В плане» — фикция без печати: правка шагов
 * меняет sig, что видно владельцу. Детерминированный матч «действие ↔ шаг».
 */
import { createHash } from 'node:crypto';
import fsDefault from 'node:fs';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';

/** Стабильный id плана = sha256 канонизированных шагов. */
export function planId(steps) {
  return createHash('sha256').update(canonicalJson(steps)).digest('hex');
}

/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
 *  artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта). */
export function freezePlan({ steps, artifactId = null, key, nowMs }) {
  const id = planId(steps);
  const base = { plan_id: id, artifact_id: artifactId, frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps };
  return { ...base, sig: signPayload(base, key) };
}

/** Печать цела? (правка шагов/времени/id → false; без ключа/подписи → false). */
export function verifyFrozenPlan(plan, key) {
  if (!plan || typeof plan !== 'object') return false;
  return verifyReceipt(plan, key);
}

export const _internals = { fsDefault };
```

- [ ] **Step 4: GREEN** — прогон → PASS (5 tests).

- [ ] **Step 5: Commit** — `git add tools/plan-lock.mjs tools/plan-lock.test.mjs && git commit -m "feat(m2): plan-lock — freeze/verify plan with HMAC hash-seal (P15-f)"`

---

## Task 2: Детерминированный матч шага (`plan-lock.mjs` ч.2)

**Files:** Modify `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/plan-lock.test.mjs — добавить
import { actionMatchesStep, nextStep } from './plan-lock.mjs';

describe('actionMatchesStep (deterministic, P15-a/e)', () => {
  const step = { n: 1, op: 'Write', object: 'tools/foo.mjs' };
  const norm = (p) => p.replace(/\\/g, '/').toLowerCase(); // stub pathNormalize

  it('matches when op AND object both equal (after normalize)', () => {
    expect(actionMatchesStep(step, { op: 'Write', object: 'tools/FOO.mjs' }, { normalize: norm })).toBe(true);
  });
  it('does NOT match when op differs (operation axis, P15-e)', () => {
    expect(actionMatchesStep(step, { op: 'Edit', object: 'tools/foo.mjs' }, { normalize: norm })).toBe(false);
  });
  it('does NOT match when object differs', () => {
    expect(actionMatchesStep(step, { op: 'Write', object: 'tools/bar.mjs' }, { normalize: norm })).toBe(false);
  });
  it('Bash matches by normalized command', () => {
    const bstep = { n: 1, op: 'Bash', object: 'npx   vitest run foo' };
    expect(actionMatchesStep(bstep, { op: 'Bash', object: 'npx vitest run foo' })).toBe(true);
  });
});

describe('nextStep (ordered pointer)', () => {
  const steps = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
  it('returns the step at the current pointer', () => {
    expect(nextStep(steps, 0)).toEqual(steps[0]);
    expect(nextStep(steps, 1)).toEqual(steps[1]);
  });
  it('returns null past the end', () => {
    expect(nextStep(steps, 2)).toBe(null);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/plan-lock.mjs — добавить
import { pathNormalize } from './path-normalization.mjs';

/** Нормализация команды Bash: схлопнуть пробелы, trim. */
function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); }

/**
 * Детерминированный матч действия и шага (P15-a + P15-e): op И object обязаны
 * совпасть. Файловые object — через normalize (default pathNormalize); Bash — через
 * normCommand. Никакого LLM, никаких зашитых списков.
 */
export function actionMatchesStep(step, action, { normalize = pathNormalize } = {}) {
  if (!step || !action) return false;
  if (String(step.op) !== String(action.op)) return false;
  if (step.op === 'Bash') return normCommand(step.object) === normCommand(action.object);
  let a, b;
  try { a = normalize(String(step.object)); b = normalize(String(action.object)); }
  catch { return false; }
  return a === b;
}

/** Шаг по указателю (или null за концом). */
export function nextStep(steps, ptr) {
  if (!Array.isArray(steps) || ptr < 0 || ptr >= steps.length) return null;
  return steps[ptr];
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): deterministic action↔step match (P15-a/e, no LLM)"`

---

## Task 3: Персист замороженного плана (`plan-lock.mjs` ч.3)

**Files:** Modify `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/plan-lock.test.mjs — добавить
import { saveFrozenPlan, loadFrozenPlan } from './plan-lock.mjs';
function memFs() {
  const s = new Map();
  return { s,
    readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
    writeFileSync: (p, d) => s.set(String(p), String(d)) };
}
describe('frozen plan persistence', () => {
  it('save then load round-trips and stays verifiable', () => {
    const fs = memFs();
    const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
    saveFrozenPlan({ plan: p, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
    const loaded = loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
    expect(verifyFrozenPlan(loaded, KEY)).toBe(true);
    expect(loaded.plan_id).toBe(p.plan_id);
  });
  it('loadFrozenPlan returns null when no plan frozen', () => {
    expect(loadFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: memFs() })).toBe(null);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/plan-lock.mjs — добавить
function planPath(runtimeDir, sessionId) {
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return `${runtimeDir}${sep}frozen-plan-${sessionId}.json`;
}
export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) {
  fsImpl.writeFileSync(planPath(runtimeDir, sessionId), JSON.stringify(plan));
}
export function loadFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  try { return JSON.parse(fsImpl.readFileSync(planPath(runtimeDir, sessionId), 'utf8')); }
  catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): frozen plan persistence (runtime, Node fs)"`

---

## Task 4: Семена-исключения (`enforce-supreme-gate.mjs` ч.1)

**Files:** Create `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

- [ ] **Step 1: Падающий тест**

```js
// tools/enforce-supreme-gate.test.mjs
import { describe, it, expect } from 'vitest';
import { isSeed, SEED_SKILLS } from './enforce-supreme-gate.mjs';

describe('isSeed (D12/D13 bootstrap exemption)', () => {
  it('EnterPlanMode and AskUserQuestion are seeds', () => {
    expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
    expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
  });
  it('Skill(writing-plans) / brainstorming / discovery-interview are seeds', () => {
    for (const s of SEED_SKILLS) {
      expect(isSeed({ name: 'Skill', input: { skill: s } })).toBe(true);
      expect(isSeed({ name: 'Skill', input: { skill: 'superpowers:' + s } })).toBe(true);
    }
  });
  it('a non-seed Skill is NOT a seed', () => {
    expect(isSeed({ name: 'Skill', input: { skill: 'coder' } })).toBe(false);
  });
  it('mutating tools are not seeds', () => {
    expect(isSeed({ name: 'Write', input: { file_path: 'x' } })).toBe(false);
    expect(isSeed({ name: 'Bash', input: { command: 'rm x' } })).toBe(false);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация**

```js
// tools/enforce-supreme-gate.mjs
#!/usr/bin/env node
/**
 * enforce-supreme-gate — Верховный хук (Машина 2): default-deny на ВСЕ действия,
 * пропуск только при шаге замороженного плана. Матчер = '*' (регистрация в
 * settings.json — owner-шаг). Семена D12/D13 пропускаются всегда (иначе стену
 * нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца).
 */
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';

// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
// открывают одобрение. Обоснование — D12/D13.
export const SEED_SKILLS = ['writing-plans', 'brainstorming', 'discovery-interview'];
const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']);

function skillSuffix(name) { const s = String(name || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; }

export function isSeed(toolUse) {
  if (!toolUse) return false;
  if (SEED_TOOLS.has(toolUse.name)) return true;
  if (toolUse.name === 'Skill') return SEED_SKILLS.includes(skillSuffix(toolUse.input?.skill));
  return false;
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): supreme-gate seeds exemption (D12/D13 bootstrap)"`

---

## Task 5: Решение верховного хука (`enforce-supreme-gate.mjs` ч.2)

**Files:** Modify `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/enforce-supreme-gate.test.mjs — добавить
import { decide, actionOf } from './enforce-supreme-gate.mjs';

const KEY = 'supreme-key';
const PLAN = {
  plan_id: 'x', frozen_at: 1,
  steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }],
  sig: 'unused-in-stub',
};
// stub verify: считаем план валидным если sig !== 'BAD'
const verifyStub = (p) => p && p.sig !== 'BAD';
const ctx = (over) => ({ key: KEY, verifyImpl: verifyStub, normalize: (p) => p.toLowerCase(), ...over });

describe('supreme-gate decide()', () => {
  it('seed passes even with no frozen plan', () => {
    const r = decide(ctx({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, stepPtr: 0 }));
    expect(r.decision).toBe('allow');
    expect(r.reason).toMatch(/seed/i);
  });
  it('default-deny when there is no frozen plan (non-seed)', () => {
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: null, stepPtr: 0 }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/нет замороженного плана|no frozen plan/i);
  });
  it('default-deny when frozen plan signature is invalid', () => {
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: { ...PLAN, sig: 'BAD' }, stepPtr: 0 }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/печать|seal|signature/i);
  });
  it('allow when action matches the current plan step', () => {
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
    expect(r.decision).toBe('allow');
    expect(r.advanceTo).toBe(1);
  });
  it('block when action does NOT match the current step (not in plan)', () => {
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/не в плане|not in plan/i);
  });
  it('actionOf maps a tool event to {op, object}', () => {
    expect(actionOf({ name: 'Write', input: { file_path: 'a.mjs' } })).toEqual({ op: 'Write', object: 'a.mjs' });
    expect(actionOf({ name: 'Bash', input: { command: 'rm x' } })).toEqual({ op: 'Bash', object: 'rm x' });
    expect(actionOf({ name: 'Skill', input: { skill: 'coder' } })).toEqual({ op: 'Skill', object: 'coder' });
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/enforce-supreme-gate.mjs — добавить
import { verifyFrozenPlan, actionMatchesStep, nextStep } from './plan-lock.mjs';

/** Привести событие инструмента к {op, object} для матча по шагу. */
export function actionOf(toolUse) {
  const op = toolUse?.name;
  const i = toolUse?.input || {};
  let object = '';
  if (op === 'Bash') object = i.command || '';
  else if (op === 'Skill') object = i.skill || '';
  else if (op === 'Task') object = i.subagent_type || '';
  else object = i.file_path || i.notebook_path || i.path || i.target_file || '';
  return { op, object };
}

/**
 * Решение верховного хука. allow только если: семя ИЛИ действие совпадает с
 * текущим шагом валидного замороженного плана. Иначе block (default-deny).
 * @returns {{decision:'allow'|'block', reason:string, advanceTo?:number}}
 */
export function decide({ toolUse, frozenPlan, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, normalize }) {
  if (isSeed(toolUse)) return { decision: 'allow', reason: 'seed (bootstrap D12/D13)' };
  if (!frozenPlan) return { decision: 'block', reason: 'нет замороженного плана — действие вне плана запрещено (default-deny)' };
  if (!verifyImpl(frozenPlan, key)) return { decision: 'block', reason: 'печать плана невалидна (seal/signature) — требуется заново одобрить план' };
  const step = nextStep(frozenPlan.steps, stepPtr);
  if (!step) return { decision: 'block', reason: 'план исчерпан — нет шага под это действие' };
  if (actionMatchesStep(step, actionOf(toolUse), { normalize })) {
    return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 };
  }
  return { decision: 'block', reason: `действие не в плане (ожидался шаг ${step.n}: ${step.op} ${step.object})` };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): supreme-gate decide — default-deny, plan-step match, seal check"`

---

## Task 6: CLI-обёртка хука (`enforce-supreme-gate.mjs` ч.3)

> **[РЕШЕНИЕ E-impl — линейный указатель шага]** (реализация решения E; не путать — E про «линейный сейчас / волны в М3»). `main()` читает событие, грузит замороженный план (`loadFrozenPlan`) + указатель шага из журнала/состояния, зовёт `decide`, на allow — продвигает указатель и пишет действие в Журнал S1 (лог-до-действия). fail-CLOSED. Ключ — `resolveReceiptKey()`. **Указатель шага** хранится в `~/.claude/runtime/plan-step-<sess>` (число, линейный). Регистрация matcher=`*` — owner-шаг.

**Files:** Modify `tools/enforce-supreme-gate.mjs`

- [ ] **Step 1: Тест для чистой `runGate` (без процесс-I/O)**

```js
// tools/enforce-supreme-gate.test.mjs — добавить
import { runGate } from './enforce-supreme-gate.mjs';
describe('runGate (pure orchestration)', () => {
  it('allow advances pointer and journals the action', () => {
    const journaled = [];
    const r = runGate({
      event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
      frozenPlan: PLAN, stepPtr: 0, key: KEY, verifyImpl: verifyStub, normalize: (p) => p.toLowerCase(),
      journal: (entry) => journaled.push(entry), saveStep: (n) => (journaled.savedStep = n),
    });
    expect(r.block).toBe(false);
    expect(journaled).toHaveLength(1);
    expect(journaled.savedStep).toBe(1);
  });
  it('block does NOT journal or advance', () => {
    const journaled = [];
    const r = runGate({
      event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
      frozenPlan: PLAN, stepPtr: 0, key: KEY, verifyImpl: verifyStub, normalize: (p) => p.toLowerCase(),
      journal: (e) => journaled.push(e), saveStep: () => {},
    });
    expect(r.block).toBe(true);
    expect(journaled).toHaveLength(0);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)** — чистый `runGate` + тонкий `main`

```js
// tools/enforce-supreme-gate.mjs — добавить
/**
 * Чистая оркестрация: decide → на allow журналирует действие и продвигает шаг.
 * journal/saveStep инъектируются (в main — реальные Node fs).
 */
export function runGate({ event, frozenPlan, stepPtr, key, verifyImpl, normalize, journal, saveStep }) {
  const toolUse = { name: event.tool_name, input: event.tool_input };
  const r = decide({ toolUse, frozenPlan, stepPtr, key, verifyImpl, normalize });
  if (r.decision === 'allow' && typeof r.advanceTo === 'number') {
    journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null });
    saveStep(r.advanceTo);
  }
  return { block: r.decision === 'block', message: r.reason };
}

async function main() {
  try {
    const event = parseEventJson(await readStdin());
    const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
    const { loadFrozenPlan } = await import('./plan-lock.mjs');
    const { journalAppend } = await import('./action-journal.mjs');
    const os = await import('node:os'); const fs = await import('node:fs');
    const runtimeDir = `${os.homedir()}/.claude/runtime`;
    const sess = process.env.CLAUDE_SESSION_ID || 'unknown';
    const key = resolveReceiptKey();
    const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir });
    const stepPath = `${runtimeDir}/plan-step-${sess}`;
    let stepPtr = 0; try { stepPtr = parseInt(fs.readFileSync(stepPath, 'utf8'), 10) || 0; } catch {}
    const r = runGate({
      event, frozenPlan, stepPtr, key,
      journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
      saveStep: (n) => fs.writeFileSync(stepPath, String(n)),
    });
    exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
  } catch {
    exitDecision({ block: true, message: '[supreme-gate] внутренняя ошибка — fail-CLOSED' });
  }
}

import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 4: GREEN** (runGate-тесты; `main` проверяется smoke-ом владельца при регистрации).
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): supreme-gate runGate + fail-CLOSED main (log-before-action)"`

---

## Task 7: Авто-аудит покрытия дверей (`door-coverage.mjs`, P15-b)

> **[РЕШЕНИЕ F — ✅ закрыто 2026-06-04]** «Дверь» = любой инструмент, способный породить действие. Аудит: дан список ВСЕХ инструментов сессии (из события/реестра) и matcher-конфиг хука; инструмент, не покрытый matcher'ом `*` и не семя, → флаг. Закрывает «забытый канал» как КЛАСС (P15-b, остаток #4/#15). **Дополнение (страховка к D, 2026-06-04):** аудит ДОПОЛНИТЕЛЬНО флагует **мутирующий** инструмент, попавший в «зелёный проход» (`seeds`/`isObserveOnly`) — единственное место, где может спрятаться опасное исключение. Реализация — расширить `auditDoors`: вернуть `flaggedExempt` = инструменты из зелёного прохода, чья способность = мутирующая.

**Files:** Create `tools/door-coverage.mjs`, `tools/door-coverage.test.mjs`

- [ ] **Step 1: Падающий тест**

```js
// tools/door-coverage.test.mjs
import { describe, it, expect } from 'vitest';
import { auditDoors } from './door-coverage.mjs';

describe('auditDoors (P15-b)', () => {
  it('flags a tool not covered by the supreme matcher', () => {
    const r = auditDoors({ tools: ['Write', 'Bash', 'WebFetch'], matcher: ['Write', 'Bash'], seeds: ['AskUserQuestion'] });
    expect(r.uncovered).toEqual(['WebFetch']);
    expect(r.ok).toBe(false);
  });
  it('star matcher covers everything → ok', () => {
    const r = auditDoors({ tools: ['Write', 'Bash', 'WebFetch'], matcher: ['*'], seeds: [] });
    expect(r.uncovered).toEqual([]);
    expect(r.ok).toBe(true);
  });
  it('seeds are not counted as uncovered', () => {
    const r = auditDoors({ tools: ['AskUserQuestion', 'Write'], matcher: ['Write'], seeds: ['AskUserQuestion'] });
    expect(r.uncovered).toEqual([]);
    expect(r.ok).toBe(true);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация**

```js
// tools/door-coverage.mjs
#!/usr/bin/env node
/**
 * door-coverage — авто-аудит покрытия дверей (P15-b): инструмент, не покрытый
 * matcher'ом верховного хука и не семя, = «забытая дверь» → флаг. Закрывает
 * пропущенный канал как КЛАСС, а не точечно (урок F1: PowerShell мимо Bash).
 */
export function auditDoors({ tools = [], matcher = [], seeds = [] }) {
  const star = matcher.includes('*');
  const cover = new Set(matcher);
  const seed = new Set(seeds);
  const uncovered = star ? [] : tools.filter((t) => !cover.has(t) && !seed.has(t));
  return { ok: uncovered.length === 0, uncovered };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): door-coverage audit — forgotten-channel as a class (P15-b)"`

---

## Task 8: Сверка план↔след по сессии (P25-d)

> **[РЕШЕНИЕ G — ✅ закрыто 2026-06-04]** Каждая запись Журнала S1 должна ложиться на шаг замороженного плана; запись-сирота (действие прошло, но шага нет) → где-то пропущен гейт → флаг владельцу. Чистая функция над (journal-entries, plan-steps). Ревизия постфактум в пару к стене (вход): стена не пускает мимо плана, G ловит то, что просочилось.

**Files:** Modify `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/plan-lock.test.mjs — добавить
import { reconcileJournalToPlan } from './plan-lock.mjs';
describe('reconcileJournalToPlan (P25-d)', () => {
  const steps = [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Bash', object: 'npx vitest' }];
  it('all journal actions map to a plan step → ok', () => {
    const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'npx vitest' }];
    expect(reconcileJournalToPlan(journal, steps).ok).toBe(true);
  });
  it('an orphan action (no matching step) is flagged', () => {
    const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'rm -rf /' }];
    const r = reconcileJournalToPlan(journal, steps);
    expect(r.ok).toBe(false);
    expect(r.orphans).toHaveLength(1);
    expect(r.orphans[0].object).toBe('rm -rf /');
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/plan-lock.mjs — добавить
/** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */
export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) {
  const orphans = (journal || []).filter(
    (a) => !(steps || []).some((s) => actionMatchesStep(s, a, { normalize }))
  );
  return { ok: orphans.length === 0, orphans };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): reconcileJournalToPlan — orphan-action flag (P25-d)"`

---

## Task 9: Интеграционные инварианты Машины 2 + полная регрессия

**Files:** Create `tools/m2-supreme-invariants.test.mjs`

- [ ] **Step 1: Инвариант-тест**

```js
// tools/m2-supreme-invariants.test.mjs
import { describe, it, expect } from 'vitest';
import { freezePlan, verifyFrozenPlan } from './plan-lock.mjs';
import { decide, isSeed } from './enforce-supreme-gate.mjs';

const KEY = 'm2-key';
const steps = [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }];

describe('Машина 2 — инварианты стены', () => {
  it('без замороженного плана любое мутирующее действие блокируется', () => {
    const r = decide({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: null, key: KEY });
    expect(r.decision).toBe('block');
  });
  it('подмена шага в плане ломает печать → блок', () => {
    const p = freezePlan({ steps, key: KEY, nowMs: 1 });
    const tampered = { ...p, steps: [{ ...p.steps[0], object: 'tools/evil.mjs' }] };
    expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
    const r = decide({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: tampered, key: KEY });
    expect(r.decision).toBe('block');
  });
  it('семя загрузки проходит без плана (стену можно построить)', () => {
    expect(isSeed({ name: 'Skill', input: { skill: 'writing-plans' } })).toBe(true);
    const r = decide({ toolUse: { name: 'Skill', input: { skill: 'writing-plans' } }, frozenPlan: null, key: KEY });
    expect(r.decision).toBe('allow');
  });
  it('действие строго по шагу валидного плана проходит', () => {
    const p = freezePlan({ steps, key: KEY, nowMs: 1 });
    const r = decide({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: p, key: KEY });
    expect(r.decision).toBe('allow');
  });
});
```

- [ ] **Step 2: GREEN** (инвариант-тест).
- [ ] **Step 3: Полная регрессия** — `npx vitest run --root app --config vitest.config.tools.mjs` → все прежние + Машина 1 + Машина 2 зелёные.
- [ ] **Step 4: Commit** — `git commit -m "test(m2): supreme-wall invariants (default-deny / seal / seed / step-match)"`

---

## Task 10: Режим разговорного плана + бэкстоп артефакта (`enforce-supreme-gate.mjs` ч.4, [РЕШЕНИЕ H])

> **C/L-надстройка (C-7/C-10).** Стена получает ДВА режима. Разговорный: плана-стройки ещё нет, пропускаем только семена, любой инструмент реализации блокируется. Реализационный: как Tasks 4–6. **Бэкстоп:** реализационный план не принимается без валидного замороженного артефакта (нельзя строить без управляемого разговора позади).

**Files:** Modify `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/enforce-supreme-gate.test.mjs — добавить
import { decideMode } from './enforce-supreme-gate.mjs';

const verifyStub2 = (p) => p && p.sig !== 'BAD';
const baseMode = (over) => ({ key: 'k', verifyImpl: verifyStub2, normalize: (p) => p.toLowerCase(), ...over });

describe('decideMode (C-7: разговорный vs реализационный)', () => {
  it('разговорная фаза (нет плана-стройки): семя проходит', () => {
    const r = decideMode(baseMode({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, frozenArtifact: null }));
    expect(r.decision).toBe('allow');
    expect(r.mode).toBe('conversational');
  });
  it('разговорная фаза: инструмент реализации блокируется (только думать/спрашивать)', () => {
    const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: null, frozenArtifact: null }));
    expect(r.decision).toBe('block');
    expect(r.mode).toBe('conversational');
    expect(r.reason).toMatch(/разговорн|только думать|спрашивать/i);
  });
  it('бэкстоп: реализационный план без замороженного артефакта → блок', () => {
    const PLAN = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
    const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLAN, frozenArtifact: null }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/артефакт|разговор/i);
  });
  it('есть артефакт+план+совпавший шаг → стройка (передаём в decide)', () => {
    const PLAN = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
    const ART = { artifact_id: 'a', sig: 'ok' };
    const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLAN, frozenArtifact: ART, stepPtr: 0 }));
    expect(r.decision).toBe('allow');
    expect(r.mode).toBe('implementation');
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/enforce-supreme-gate.mjs — добавить
/**
 * Выбор режима стены (C-7). Нет замороженного плана-стройки → разговорный режим:
 * пропускаем только семена, остальное block. Есть план → реализационный режим,
 * НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10).
 */
export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, normalize }) {
  if (!frozenPlan) {
    if (isSeed(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed (разговорный режим: думать/спрашивать)' };
    return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' };
  }
  if (!frozenArtifact || !verifyImpl(frozenArtifact, key)) {
    if (isSeed(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed' };
    return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' };
  }
  return { ...decide({ toolUse, frozenPlan, stepPtr, key, verifyImpl, normalize }), mode: 'implementation' };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): conversational vs implementation mode + artifact backstop (C-7/C-10)"`

---

## Task 11: Вторая печать — заморозка артефакта (`plan-lock.mjs` ч.4, [РЕШЕНИЕ I])

> **C/L-надстройка (C-10).** Артефакт разговорной фазы (бриф/спека) морозится той же HMAC-печатью, что и план. Правка артефакта = новая печать (видно владельцу). Переиспользует примитив `freezePlan`.

**Files:** Modify `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/plan-lock.test.mjs — добавить
import { freezeArtifact, verifyFrozenArtifact, artifactId } from './plan-lock.mjs';
const ART = { sections: { '§1': 'палитра teal', '§2': 'шрифт Inter' }, goal: 'лендинг' };

describe('freezeArtifact / verifyFrozenArtifact (вторая печать, C-10)', () => {
  it('freezeArtifact returns a signed artifact with stable id', () => {
    const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
    expect(a.artifact_id).toMatch(/^[0-9a-f]{64}$/);
    expect(a.sig).toMatch(/^[0-9a-f]{64}$/);
  });
  it('verify true for intact, false when a section tampered (re-seal required)', () => {
    const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
    expect(verifyFrozenArtifact(a, KEY)).toBe(true);
    const tampered = { ...a, sections: { ...a.sections, '§1': 'evil' } };
    expect(verifyFrozenArtifact(tampered, KEY)).toBe(false);
  });
  it('artifactId deterministic over content', () => {
    expect(artifactId(ART)).toBe(artifactId({ ...ART }));
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/plan-lock.mjs — добавить
/** Стабильный id артефакта = sha256 канонизированного содержания. */
export function artifactId(artifact) {
  return createHash('sha256').update(canonicalJson(artifact)).digest('hex');
}
/** Заморозить артефакт разговорной фазы (вторая печать). */
export function freezeArtifact({ artifact, key, nowMs }) {
  const base = { ...artifact, artifact_id: artifactId(artifact), frozen_at: typeof nowMs === 'number' ? nowMs : Date.now() };
  return { ...base, sig: signPayload(base, key) };
}
/** Печать артефакта цела? */
export function verifyFrozenArtifact(artifact, key) {
  if (!artifact || typeof artifact !== 'object') return false;
  return verifyReceipt(artifact, key);
}

// Персист артефакта (как у плана, Task 3) — нужен рантайму: main() грузит его,
// стена сверяет версию (artifact_id) и резолвит ссылки шагов (закрытая дверь).
function artifactPath(runtimeDir, sessionId) {
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return `${runtimeDir}${sep}frozen-artifact-${sessionId}.json`;
}
export function saveFrozenArtifact({ artifact, sessionId, runtimeDir, fsImpl = fsDefault }) {
  fsImpl.writeFileSync(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact));
}
export function loadFrozenArtifact({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  try { return JSON.parse(fsImpl.readFileSync(artifactPath(runtimeDir, sessionId), 'utf8')); }
  catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
```

Добавить в Step 1 тест на round-trip персиста (по образцу Task 3):
```js
// tools/plan-lock.test.mjs — добавить
import { saveFrozenArtifact, loadFrozenArtifact } from './plan-lock.mjs';
it('artifact save/load round-trips and stays verifiable', () => {
  const fs = memFs(); // из Task 3
  const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
  saveFrozenArtifact({ artifact: a, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
  const loaded = loadFrozenArtifact({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
  expect(verifyFrozenArtifact(loaded, KEY)).toBe(true);
  expect(loaded.artifact_id).toBe(a.artifact_id);
});
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): second seal + artifact persistence (freeze/save/load, C-10)"`

---

## Task 12: Резолвер закрытой двери (`plan-lock.mjs` + `enforce-supreme-gate.mjs` ч.5, [РЕШЕНИЕ J])

> **C/L-надстройка (C-5).** Шаг реализации обязан ссылаться на опечатанное решение артефакта (`ref: §X`). После совпадения op+object стена ДОПОЛНИТЕЛЬНО проверяет: ссылка резолвится в опечатанном артефакте? Нет → блок «вернись в разговор, пере-печатай». «Спросил на ходу и продолжил» физически невозможен.

**Files:** Modify `tools/plan-lock.mjs`, `tools/enforce-supreme-gate.mjs` (+тесты)

- [ ] **Step 1: Падающие тесты**

```js
// tools/plan-lock.test.mjs — добавить
import { refResolves } from './plan-lock.mjs';
const ARTSEC = { sections: { '§1': 'teal', '§2': 'Inter' } };

describe('refResolves (закрытая дверь, C-5)', () => {
  it('ссылка шага резолвится в опечатанном артефакте', () => {
    expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§1' }, ARTSEC)).toBe(true);
  });
  it('несуществующая ссылка → не резолвится', () => {
    expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§9' }, ARTSEC)).toBe(false);
  });
  it('шаг без ref (простой/legacy) → резолв не требуется (true)', () => {
    expect(refResolves({ n: 1, op: 'Write', object: 'x' }, ARTSEC)).toBe(true);
  });
});
```

```js
// tools/enforce-supreme-gate.test.mjs — добавить (decide со связкой артефакта)
const ARTID = 'aid-1';
const PLAN_REF = { plan_id: 'p', artifact_id: ARTID, frozen_at: 1,
  steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
const ART_OK = { artifact_id: ARTID, sections: { '§1': 'teal' }, sig: 'ok' };

describe('decide() закрытая дверь + привязка к версии артефакта (C-5)', () => {
  it('совпавший шаг + ссылка резолвится + версия совпала → allow', () => {
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
      frozenPlan: PLAN_REF, frozenArtifact: ART_OK, stepPtr: 0 }));
    expect(r.decision).toBe('allow');
  });
  it('ссылка шага не резолвится в артефакте → block', () => {
    const planBadRef = { ...PLAN_REF, steps: [{ ...PLAN_REF.steps[0], ref: '§9' }] };
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
      frozenPlan: planBadRef, frozenArtifact: ART_OK, stepPtr: 0 }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/не резолвится|закрыт|C-5/i);
  });
  it('версия артефакта не та, под которую печатали план → block', () => {
    const otherArt = { ...ART_OK, artifact_id: 'aid-2' };
    const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
      frozenPlan: PLAN_REF, frozenArtifact: otherArt, stepPtr: 0 }));
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/версия|artifact|пере-печать/i);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/plan-lock.mjs — добавить
/**
 * Закрытая дверь (C-5): если у шага есть ссылка ref на решение артефакта,
 * она ОБЯЗАНА резолвиться в опечатанном артефакте. Нет ref → требование снято
 * (простой шаг). Есть ref, но раздела нет → false (стена блокирует).
 */
export function refResolves(step, frozenArtifact) {
  if (!step || !step.ref) return true;
  const sections = (frozenArtifact && frozenArtifact.sections) || {};
  return Object.prototype.hasOwnProperty.call(sections, step.ref);
}
```

`decide()` (Task 5) **заменяется** на версию с доп. входом `frozenArtifact` и проверкой закрытой двери ПОСЛЕ совпадения шага. **NB порядок:** строка `isObserveOnly` ниже определяется в Task 13 — выполнить **Task 13 ДО** этой замены (или определить `isObserveOnly` рядом), иначе ссылка на неопределённую функцию.
```js
// tools/enforce-supreme-gate.mjs — заменить decide() на:
import { verifyFrozenPlan, actionMatchesStep, nextStep, refResolves } from './plan-lock.mjs';

export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, normalize }) {
  if (isSeed(toolUse)) return { decision: 'allow', reason: 'seed (bootstrap D12/D13)' };
  if (isObserveOnly(toolUse)) return { decision: 'allow', reason: 'observe-only (зелёный проход по способности)' }; // Task 13
  if (!frozenPlan) return { decision: 'block', reason: 'нет замороженного плана — действие вне плана запрещено (default-deny)' };
  if (!verifyImpl(frozenPlan, key)) return { decision: 'block', reason: 'печать плана невалидна (seal/signature) — требуется заново одобрить план' };
  const step = nextStep(frozenPlan.steps, stepPtr);
  if (!step) return { decision: 'block', reason: 'план исчерпан — нет шага под это действие' };
  if (!actionMatchesStep(step, actionOf(toolUse), { normalize }))
    return { decision: 'block', reason: `действие не в плане (ожидался шаг ${step.n}: ${step.op} ${step.object})` };
  // Закрытая дверь (C-5) + привязка к версии артефакта — только если план опирается на артефакт:
  if (frozenPlan.artifact_id) {
    if (!frozenArtifact || frozenArtifact.artifact_id !== frozenPlan.artifact_id)
      return { decision: 'block', reason: `артефакт не той версии, под которую печатали план (ждём ${frozenPlan.artifact_id}) — пере-печать` };
    if (!refResolves(step, frozenArtifact))
      return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5)` };
  }
  return { decision: 'allow', reason: `шаг ${step.n} плана`, advanceTo: stepPtr + 1 };
}
```

`decideMode()` (Task 10) — **прокинуть артефакт в `decide()`** (последняя строка):
```js
  return { ...decide({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, normalize }), mode: 'implementation' };
```

`runGate()` и `main()` (Task 6) — **грузить и передавать артефакт**:
```js
// runGate: добавить frozenArtifact в аргументы и в вызов decide/decideMode
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, normalize, journal, saveStep }) {
  const toolUse = { name: event.tool_name, input: event.tool_input };
  const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, normalize });
  if (r.decision === 'allow' && typeof r.advanceTo === 'number') {
    journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null });
    saveStep(r.advanceTo);
  }
  return { block: r.decision === 'block', message: r.reason };
}
// main(): после loadFrozenPlan добавить
//   const { loadFrozenArtifact } = await import('./plan-lock.mjs');
//   const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir });
// и передать frozenArtifact в runGate(...).
```

- [ ] **Step 4: GREEN** (refResolves-юнит + decide-блокировка при неразрешённой ссылке И при несовпадении версии артефакта; прежние decide-тесты Task 5 целы — у тех планов нет `artifact_id`, дверь пропускается).
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): closed-door + artifact-version binding wired into decide/decideMode/main (C-5)"`

---

## Task 13: Зелёный проход — только-смотрящие инструменты ([РЕШЕНИЕ D], закрытие пробела)

> **Зелёный проход = единое правило по СПОСОБНОСТИ: «нет долговременного И нет исходящего эффекта»** (finding 9, ревизия 2026-06-04). Иначе always-on дисциплина (болтовни нет) задушит работу. Под правило: **Read/Grep/Glob** (смотрят); **read-only Bash** (`git status`/`log`/`grep`) — по ЭФФЕКТУ через `classifyBashCommand` (**условие А:** классификатор консервативен — редирект `>`/`>>`/`|` в писатель и пишущие подкоманды `git config`/`gc`/`tee`/`$(...)` → НЕ readonly); **TodoWrite** и эфемерные мета — меняют только черновик сессии, не мир. **НЕ под правило:** Write/Edit/MultiEdit/мутирующий Bash/Task/Skill(не-семя) → стена; WebFetch/WebSearch/внешний MCP → исходящий канал, держит пол/судья.
> **Условие Б (контракт к Машине 4):** TodoWrite в зелёном проходе безопасен ТОЛЬКО потому, что «взял ли я навык» сверяется по фактическому **журналу вызовов** (судья A2), а НЕ по тексту TodoWrite. Без этой журнальной сверки зелёный TodoWrite открыл бы фейковое покрытие — поэтому она обязательна (зависимость зафиксирована).

**Files:** Modify `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/enforce-supreme-gate.test.mjs — добавить
import { isObserveOnly } from './enforce-supreme-gate.mjs';

// stub classifyBash: readonly только для «безопасных» команд, редирект/писатель → не readonly (условие А)
const classifyBashStub = (cmd) => {
  if (/[>|]|git\s+(config|gc|push|commit)|tee\b|\$\(/.test(cmd)) return { result: 'block', reason: 'mutating' };
  if (/^(git\s+(status|log|diff|show)|grep|cat|ls)\b/.test(cmd)) return { result: 'allow', reason: 'readonly' };
  return { result: 'block', reason: 'unknown' };
};

describe('isObserveOnly (зелёный проход = нет долговременного/исходящего эффекта, finding 9)', () => {
  it('локальные смотрящие — да', () => {
    for (const n of ['Read', 'Grep', 'Glob']) expect(isObserveOnly({ name: n })).toBe(true);
  });
  it('TodoWrite (эфемерный черновик) — да', () => {
    expect(isObserveOnly({ name: 'TodoWrite', input: {} })).toBe(true);
  });
  it('read-only Bash по эффекту — да; редирект/писатель — НЕТ (условие А)', () => {
    expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } }, { classifyBash: classifyBashStub })).toBe(true);
    expect(isObserveOnly({ name: 'Bash', input: { command: 'git log > evil.sh' } }, { classifyBash: classifyBashStub })).toBe(false);
    expect(isObserveOnly({ name: 'Bash', input: { command: 'git config x y' } }, { classifyBash: classifyBashStub })).toBe(false);
  });
  it('мутирующие — НЕТ', () => {
    for (const n of ['Write', 'Edit', 'MultiEdit', 'Task']) expect(isObserveOnly({ name: n })).toBe(false);
  });
  it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => {
    expect(isObserveOnly({ name: 'WebFetch' })).toBe(false);
    expect(isObserveOnly({ name: 'WebSearch' })).toBe(false);
  });
});

describe('decide() пропускает зелёный проход без шага плана', () => {
  it('Read проходит без плана', () => {
    const r = decide(ctx({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: null, stepPtr: 0 }));
    expect(r.decision).toBe('allow');
    expect(r.reason).toMatch(/observe|смотр|зелён/i);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/enforce-supreme-gate.mjs — добавить
import { classifyBashCommand } from './shell-content-rules.mjs';

// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
const OBSERVE_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']);   // локальные «смотрят»
const EPHEMERAL_META_TOOLS = new Set(['TodoWrite']);            // меняют только черновик сессии, не мир
export function isObserveOnly(toolUse, { classifyBash = classifyBashCommand } = {}) {
  if (!toolUse) return false;
  if (OBSERVE_ONLY_TOOLS.has(toolUse.name)) return true;
  if (EPHEMERAL_META_TOOLS.has(toolUse.name)) return true;
  // Условие А: read-only Bash по ЭФФЕКТУ; classifyBash консервативен (редирект/писатель → НЕ readonly).
  if (toolUse.name === 'Bash') {
    const c = classifyBash(toolUse.input?.command || '');
    return !!c && c.result === 'allow' && /readonly|reading/.test(c.reason || '');
  }
  // Исходящие (WebFetch/WebSearch/внешний MCP) и мутирующие — НЕ зелёный проход (пол/судья/стена).
  return false;
}
```

В `decide()` — сразу ПОСЛЕ проверки `isSeed`, ДО проверки `!frozenPlan`:
```js
  if (isObserveOnly(toolUse)) return { decision: 'allow', reason: 'observe-only (зелёный проход по способности — не меняет состояние)' };
```
В `decideMode()` — в разговорной ветке (`!frozenPlan`) рядом с `isSeed` тоже пропускать observe-only:
```js
    if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (разговорный режим)' };
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): green-pass observe-only tools by capability (D)"`

---

## Task 14: Аудит «зелёного прохода» — мутирующий в исключениях ([РЕШЕНИЕ F], страховка к D)

> F дополнен: `door-coverage` флагует не только непокрытые двери, но и инструмент, попавший в зелёный проход (`seeds`/`isObserveOnly`), чья способность = **мутирующая** (этого быть не должно никогда). Единственное место, где может спрятаться опасное исключение.

**Files:** Modify `tools/door-coverage.mjs`, `tools/door-coverage.test.mjs`

- [ ] **Step 1: Падающие тесты**

```js
// tools/door-coverage.test.mjs — добавить
import { auditExempt } from './door-coverage.mjs';

describe('auditExempt (страховка зелёного прохода, F)', () => {
  // классификатор мутации инъектируется (в проде — общий с supreme-gate)
  const SAFE = new Set(['Read', 'Grep', 'Glob', 'LS', 'AskUserQuestion', 'EnterPlanMode']);
  const isMutating = (t) => !SAFE.has(t) && !String(t).startsWith('Skill:');

  it('мутирующий инструмент в зелёном проходе → флаг', () => {
    const r = auditExempt({ exempt: ['Read', 'Bash'], isMutating });
    expect(r.ok).toBe(false);
    expect(r.flagged).toEqual(['Bash']);
  });
  it('только-смотрящие + семена в зелёном проходе → ok', () => {
    const r = auditExempt({ exempt: ['Read', 'Grep', 'AskUserQuestion'], isMutating });
    expect(r.ok).toBe(true);
    expect(r.flagged).toEqual([]);
  });
});
```

- [ ] **Step 2: RED.**

- [ ] **Step 3: Реализация (дописать)**

```js
// tools/door-coverage.mjs — добавить
/**
 * Страховка к D (F): инструмент в «зелёном проходе» (seeds/observe), чья способность
 * мутирующая, = опасное исключение → флаг. isMutating инъектируется (общий критерий
 * способности с supreme-gate), чтобы не плодить зашитых списков.
 */
export function auditExempt({ exempt = [], isMutating }) {
  const flagged = exempt.filter((t) => isMutating(t));
  return { ok: flagged.length === 0, flagged };
}
```

- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit** — `git commit -m "feat(m2): audit green-pass for mutating exemptions (F safety to D)"`

---

## Self-Review (против §«МАШИНА 2» карты)

**Покрытие (реализационная стена, Tasks 1–9):** замороженный план P15-f → Task 1; матч op+object P15-a/e → Task 2; персист → Task 3; default-deny + матчер=* (в main) + семена → Task 4–6; авто-аудит дверей P15-b → Task 7; сверка план↔след P25-d → Task 8; инварианты → Task 9. **fail-CLOSED** → Task 6 main. **A0 (подсудный не контролирует ворота):** хук срабатывает структурно на каждый tool (matcher=*), его не «зовут по желанию» — это свойство регистрации (owner-шаг), отмечено.

**Покрытие (C/L-надстройка, Tasks 10–12):** два режима стены — разговорный/реализационный + бэкстоп артефакта (C-7/C-10) → Task 10; вторая печать — заморозка артефакта (C-10) → Task 11; резолвер закрытой двери, шаг↔§ (C-5) → Task 12. **Контракты скилов + L-поля (C-13/L-серия), машина охвата A/B/C/D (C-14), look-ahead, апелляционный цикл (C-12)** — НЕ здесь: по мастер-карте уходят в Машину 3 (роутер: контракты/охват/look-ahead/L-ядро) и Машину 4 (судья: два гейта/апелляция/L7); счётчик раундов — журнал М1. Машина 2 даёт рамку: два режима + две печати + закрытая дверь. **Закрытие пробелов после ревью (2026-06-04):** зелёный проход только-смотрящих по способности (D) → Task 13; аудит зелёного прохода на мутирующее исключение (F, страховка к D) → Task 14.

**Fresh-eyes ревизия 2026-06-04 (adversarial, без призраков) — найдены и частично закрыты:**
- ✅ **Закрытая дверь починена (findings 1–5):** добавлены `ref` в шаг + `artifact_id` в план (привязка к версии); `decide()` получает `frozenArtifact` и проверяет версию+ссылку ПОСЛЕ матча (Task 12, было мёртвым комментарием); `save/load` артефакта + загрузка в `main()` (Task 11/12); контракт «печать ставит только процесс одобрения, не контроллер» ([РЕШЕНИЕ I]).
- ✅ **Findings 6/7 разрешены (ревизия — не простой перенос):** **D29** снята как отдельная сущность — её роль выполняет слой общения C-7 (артефакт + печать владельца на гейте-1) + закрытая дверь (план ↔ опечатанный артефакт); механический хвост «просьба→шаг» = C-14-C (М3/М4). **D33** расщеплён: применение режима — механически в Машине 2 (`decideMode`, уже есть); смысловой разбор (болтовня vs думанье, нудж к навыку) → Машина 3; **чем МЕХАНИЧЕСКИ включается слой общения** — это finding 8 (решается в М2).
- ✅ **Finding 8 растворён (призма владельца 2026-06-04):** болтовни в этой среде нет (она → claude.ai) → off-состояния нет → механический триггер «включения слоя» не нужен; ДВА режима (разговорный/реализационный), дисциплина всегда на с первого сообщения. Под-вопрос C-10 (артефакт к владельцу) → дефолт **«владелец видит артефакт ВСЕГДА на гейте-1»**, политика Машины 4.
- ✅ **Finding 9 закрыт (правило + условия А/Б/В):** зелёный проход = «нет долговременного И нет исходящего эффекта» (Read/Grep/Glob + read-only Bash по `classifyBashCommand` + TodoWrite). **А** — classifyBash консервативен к редиректам/писателям; **Б** — покрытие навыком сверяется по ЖУРНАЛУ (М4 A2), не по тексту TodoWrite (контракт к М4); **В** — в разговорном режиме свободный Write закрыт, артефакт материализуется только каналом одобрения гейта-1 (не freestyle-Write). Минор 10 (`LS`) убран. Task 13 + [РЕШЕНИЕ D/H] обновлены.
- ✅ Минор 11/12 поправлен (ярлык Task 6 → «[РЕШЕНИЕ E-impl — линейный указатель]», устранена путаница с решением E). Минор 10 (`LS`) — убран ранее.
- ✅ **Флажок закрыт (выбор владельца — патчить):** дизайн-спека `2026-06-01-...design-notes.md` синхронизирована под призму — D33 получил [РЕВИЗИЮ 2026-06-04] (болтовни нет → два режима), баннер секции D30–D33 + указатель «канон = чертёж Машины 2». **Открытых хвостов ревизии нет.**

**Заглушки:** код полный. Проектные точки помечены `[РЕШЕНИЕ A–K]` — сознательные дефолты из духа карты, НЕ заглушки. **Статус: A–G/L закрыты; H–K приняты; дыра закрытой двери (1–5) починена. До исполнения остаётся разобрать findings 6–9 (+мелочи).**

**Несовпадение с реальностью (под надзор):** (1) **частично закрыто фиксом ревью:** контракт «печать ставит только процесс одобрения, не контроллер» зафиксирован ([РЕШЕНИЕ I]); persistence артефакта добавлена (Task 11). Остаётся конкретный канал/триггер заморозки (какое именно одобрение гейта-1 → `freezeArtifact`/`freezePlan`) — оформляется при сборке Машины 4 (гейт-1) + канала Машины 1; (2) `step_ptr` в runtime — линейная модель (**решение E закрыто: линейный сейчас**); при ветвлении плана («волны» D6/OQ1) потребуется дерево указателей — ОТЛОЖЕНО в Машину 3 (помечено в мастер-карте); (3) формат секций артефакта (`§X`) для закрытой двери — простая модель (ключи-разделы); реальная схема артефакта согласуется при сборке контракта (М3/М4).

---

## СЛЕДУЮЩИЙ ШАГ
Чертёж Машины 2 актуализирован под C/L (2026-06-04): стена реализационного плана (Tasks 1–9) + надстройка двух планов / двух печатей / закрытой двери (Tasks 10–12). Стоит на проверенном фундаменте Машины 1 (✅ собрана). Перед сборкой — **владелец подтверждает D/E/F/G** (и заодно дефолты H–K). Исполнение — `subagent-driven-development`. Дальше — Машина 3 (Роутер-наставник): контракты скилов, машина охвата A/B/C/D, look-ahead, L-ядро; и Машина 4 (Судья): два гейта + апелляционный цикл.

---

## АУДИТ МАШИНЫ 2 (2026-06-05) — куча: соответствие + хвосты + замечания

Адверсари-сверка построчно (3 модуля + зависимость `classifyBashCommand`). **Все задачи собраны, логика стены цела, полная регрессия 2224 GREEN.** Брешей нет; зелёный проход Bash безопасен (редиректы хард-блокируются в `classifyBashCommand`).

### ⏸ Хвосты — сознательно отложено (меж-машинные контракты, НЕ дыры)
- **T1** `[owner]` — регистрация matcher `*` в `.claude/settings.json` (без неё стена инертна).
- **T2** `[→ M4, K1]` — `freezePlan/freezeArtifact` зовётся кем угодно с ключом; «печатает только гейт-1» — судья.
- **T3** `[→ M4, K2]` — покрытие навыком по журналу, не по тексту.
- **T4** `[→ M5, K3]` — консервативный `classifyBashCommand` (сейчас безопасен через redirect-blacklist).
- **T5** `[→ M3]` — волны (дерево шагов); `step-pointer` построен, не вшит.
- **T6** `[→ M3 3-D, K4]` — узкое Write-исключение; сейчас Write в разговоре блокируется (верно).
- **T7** — `door-coverage` не вшит в живой прогон/STATUS (утилита есть, surfacing отложен).
- **T8** — `buildFrozenPlanFromApproval` не построен (опционально; канал печати — M4).
- **T9 (было B3)** `[→ M4, K6 — ВШИТ 2026-06-05]` — анти-откат high-water-mark. Несрываемый контракт судьи (§Машина 4 + аварийный блок выше). M2 сознательно НЕ строит (зарубка в runtime = призрак).

### ⚠️ Замечания — непреднамеренные, минорные
- **B-F1** `[ИСПРАВЛЕНО 2026-06-05]` — `actionOf` брал объект из 4 полей (как до фикса B4); MCP-write с полем `filename`/`uri` не сматчился бы с шагом → блок (fail-closed, но рубит легитимный шаг). Фикс: список полей объекта выровнен с расширенным B4.
- **B-F2** `[ИСПРАВЛЕНО 2026-06-05]` — `decide()` при `artifact_id` проверял только равенство id (печать артефакта проверяла обёртка `decideMode`). Фикс: `decide()` теперь сам проверяет печать артефакта (самодостаточность, защита-в-глубину).
- **B-F3** `[residual]` — указатель шага `plan-step-<sess>` не подписан; целостность держится на `runtime-write-deny` (пол). Приемлемо; связан с T9/K6 (откат указателя — тот же класс).

### Статус
- **Исправлено кодом (TDD):** F1 (`actionOf` поля), F2 (`decide` проверяет печать артефакта).
- **Записано/отложено:** T1–T8 (контракты M3/M4/M5 + owner-matcher), T9/B3→K6 (уже вшит в M4), F3 (residual).
- **Брешей нет.** Стена логически верна.
