# Машина 1 — Фундамент (Router-mentor) — детальный план реализации

> **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 for tracking.

**Goal:** построить неподделываемый фундамент роутер-наставника — append-only хеш-цепочку действий (Журнал S1), подпись расписок HMAC-ключом из OS-keychain, и усилить защиту секретов/служебки (runtime-write-deny / read-path-deny / signed askuser-расписки), на котором стоят все остальные машины.

**Architecture:** чистое ядро (pure-функции, инъекция I/O) + тонкие CLI-обёртки хуков — established-паттерн проекта (`decide()` + `main()` через `enforce-hook-helpers.mjs`). Журнал и расписки — Node-модули, пишутся в `~/.claude/runtime/` через Node `fs` (НЕ через Write-tool, поэтому `runtime-write-deny` их не блокирует). Подпись — HMAC-SHA256, ключ резолвится зеркалом `llm-judge-config.mjs` (keychain → env → null). Цепочка журнала — `sha256(prev_hash + canonicalJson(payload))`, идея из PostgreSQL-функции `audit_chain_hash` (`db/schema.sql:75`), голова цепи HMAC-подписана (оптовую перезапись истории нельзя — нужен ключ-вне-меня).

**Tech Stack:** Node.js (ESM `.mjs`), `node:crypto` (`createHash`/`createHmac`), `keytar` (опционально, lazy-require), vitest (`tools/*.test.mjs`), существующие `tools/path-normalization.mjs` + `tools/enforce-hook-helpers.mjs`.

**Источник истины (что реализуем):** Машина 1 из мастер-карты `docs/superpowers/plans/2026-06-03-router-mentor-master-roadmap.md` (раздел «МАШИНА 1 — ФУНДАМЕНТ») + несущие №1 (журнал S1) и №5 (подпись расписок P10-c) + P10-a/P10-b (усиление runtime-write-deny) + read-path-deny over-block-fix (CLAUDE.md v2.43).

---

## Контекст исполнения (прочитать ДО Task 1)

- **Worktree:** сборка кода — в **отдельном worktree от `main`** (НЕ в `brainrepo` — там только документы дизайна). Создать через `superpowers:using-git-worktrees` на старте исполнения (предложенное имя ветки: `router-mentor-m1`). Этот план-файл живёт в `brainrepo`, код строится в `router-mentor-m1`.
- **Тесты гоняются из корня репозитория** командой:
  `npx vitest run --root app --config vitest.config.tools.mjs <фильтр>`
  где `<фильтр>` — подстрока имени файла (напр. `action-journal`). Полный прогон — без фильтра. (`npm run test:tools` сломан параллельной keytar-установкой — НЕ использовать, см. CLAUDE.md.)
- **Коммиты:** git в этом окружении под гейтом (router-gate) + бывает «призрак» parallel-session-lock. Если `git commit` из хука отбит — владелец вставляет ту же строку в терминал VS Code (надёжный путь). Шаги Commit в плане пишут стандартные команды; способ доставки коммита — на усмотрение исполнителя/владельца.
- **Регистрация в `.claude/settings.json`** — НЕ требуется для новых модулей Машины 1 (`action-journal.mjs`, `receipt-sign.mjs`, `receipt-key-config.mjs` — это **библиотеки**, их импортируют машины 2/4, а не хуки PreToolUse). Изменяемые хуки (`enforce-runtime-write-deny`, `enforce-read-path-deny`) уже зарегистрированы — их регистрацию не трогаем.
- **HMAC-ключ расписок** провижинит ВЛАДЕЛЕЦ (как LLM-ключ судьи) — кладёт в keychain (service `router-mentor-receipts`, account `default`) ИЛИ env `ROUTER_MENTOR_RECEIPT_KEY`. Это отдельный owner-шаг, в плане НЕ автоматизируется. Без ключа подпись возвращает `null`, верификация — `false` (fail-closed: неподписанная расписка невалидна).

---

## Структура файлов (что создаём / меняем)

**Создаём:**
- `tools/receipt-key-config.mjs` — резолв HMAC-ключа расписок (keychain → env → null). Зеркало `llm-judge-config.mjs`.
- `tools/receipt-sign.mjs` — чистое: `canonicalJson`, `signPayload`, `verifyReceipt`. Подпись/проверка любого объекта.
- `tools/action-journal.mjs` — чистое ядро Журнала S1: `computeEntryHash`, `appendEntry`, `verifyChain` (цепочка + HMAC-голова) + персист `journalAppend` (инъекция fs).
- Тесты: `tools/receipt-key-config.test.mjs`, `tools/receipt-sign.test.mjs`, `tools/action-journal.test.mjs`.

**Меняем:**
- `tools/path-normalization.mjs` — добавить NFC-нормализацию (P10-b, Unicode-эвейжн).
- `tools/enforce-runtime-write-deny.mjs` — обобщить извлечение пути на любой инструмент с path-полем (P10-a, не только 4 именованных Write-tool'а; ловит MCP-писатели).
- `tools/askuser-answer-parser.mjs` — добавить `signApprovalRecord` / `verifyApprovalRecord` (расписки AskUser подписаны, P10-c).
- Тесты: дописать в существующие `tools/enforce-runtime-write-deny.test.mjs`, `tools/path-normalization.test.mjs`, `tools/askuser-answer-parser.test.mjs`; добавить regression-lock в `tools/enforce-read-path-deny.test.mjs`.

**Порядок задач (по зависимостям):** подпись (Task 1–3) → журнал (Task 4–5) → усиление защиты-пола (Task 6–8) → подписанные расписки (Task 9) → интеграционный self-verify (Task 10).

---

## Task 1: Резолв HMAC-ключа расписок (`receipt-key-config.mjs`)

Зеркало `llm-judge-config.mjs`: ключ из keychain → env → null; никогда не бросает; инъекция `keychainGet` для тестов.

**Files:**
- Create: `tools/receipt-key-config.mjs`
- Test: `tools/receipt-key-config.test.mjs`

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

```js
// tools/receipt-key-config.test.mjs
import { describe, it, expect } from 'vitest';
import { resolveReceiptKey } from './receipt-key-config.mjs';

describe('resolveReceiptKey', () => {
  it('returns keychain key when present', () => {
    const r = resolveReceiptKey({ env: {}, keychainGet: () => 'k-from-chain' });
    expect(r).toBe('k-from-chain');
  });
  it('falls back to env ROUTER_MENTOR_RECEIPT_KEY when keychain empty', () => {
    const r = resolveReceiptKey({ env: { ROUTER_MENTOR_RECEIPT_KEY: 'k-env' }, keychainGet: () => null });
    expect(r).toBe('k-env');
  });
  it('prefers keychain over env', () => {
    const r = resolveReceiptKey({ env: { ROUTER_MENTOR_RECEIPT_KEY: 'k-env' }, keychainGet: () => 'k-chain' });
    expect(r).toBe('k-chain');
  });
  it('returns null when neither present', () => {
    expect(resolveReceiptKey({ env: {}, keychainGet: () => null })).toBe(null);
  });
  it('never throws when keychainGet throws → null', () => {
    const r = resolveReceiptKey({ env: {}, keychainGet: () => { throw new Error('boom'); } });
    expect(r).toBe(null);
  });
});
```

- [ ] **Step 2: Прогнать — убедиться что падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-key-config`
Expected: FAIL — `Failed to resolve import "./receipt-key-config.mjs"`.

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

```js
// tools/receipt-key-config.mjs
#!/usr/bin/env node
/**
 * receipt-key-config — резолв HMAC-ключа подписи расписок роутер-наставника.
 * Зеркало llm-judge-config.mjs: keychain (service router-mentor-receipts) → env → null.
 * Ключ провижинит владелец (как LLM-ключ судьи). Без ключа — null → подпись невозможна
 * → неподписанная расписка невалидна (fail-closed на стороне verifyReceipt).
 */
import { createRequire } from 'node:module';

const KEY_ENV = 'ROUTER_MENTOR_RECEIPT_KEY';
const KEYCHAIN_SERVICE = 'router-mentor-receipts';
const KEYCHAIN_ACCOUNT = 'default';

/**
 * @param {object} [args]
 * @param {object} [args.env]          - окружение (default process.env)
 * @param {Function} [args.keychainGet] - () => string|null (инъекция для тестов)
 * @returns {string|null}
 */
export function resolveReceiptKey({ env = process.env, keychainGet = defaultKeychainGet } = {}) {
  let chainKey = null;
  try {
    const v = keychainGet();
    chainKey = v ? String(v) : null;
  } catch {
    chainKey = null;
  }
  const envKey = env[KEY_ENV] ? String(env[KEY_ENV]) : null;
  return chainKey || envKey || null;
}

/** Lazy keytar-ридер; null если keytar отсутствует или записи нет. Никогда не бросает. */
export function defaultKeychainGet() {
  try {
    const require = createRequire(import.meta.url);
    const keytar = require('keytar');
    return (keytar.getPasswordSync?.(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)) || null;
  } catch {
    return null;
  }
}

export const _internals = { KEY_ENV, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT };
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-key-config`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/receipt-key-config.mjs tools/receipt-key-config.test.mjs
git commit -m "feat(m1): receipt-key-config — HMAC key resolution (keychain → env → null)"
```

---

## Task 2: Канонический JSON (`receipt-sign.mjs` — часть 1)

Стабильная сериализация (сортировка ключей, без пробелов) — основа и подписи, и хеш-цепочки. Без неё HMAC/хеш зависят от порядка ключей и ломаются.

**Files:**
- Create: `tools/receipt-sign.mjs`
- Test: `tools/receipt-sign.test.mjs`

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

```js
// tools/receipt-sign.test.mjs
import { describe, it, expect } from 'vitest';
import { canonicalJson } from './receipt-sign.mjs';

describe('canonicalJson', () => {
  it('sorts object keys deterministically', () => {
    expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
  });
  it('is stable regardless of insertion order', () => {
    expect(canonicalJson({ a: 1, b: 2 })).toBe(canonicalJson({ b: 2, a: 1 }));
  });
  it('recurses into nested objects and arrays', () => {
    expect(canonicalJson({ z: [3, { y: 1, x: 2 }], a: 0 }))
      .toBe('{"a":0,"z":[3,{"x":2,"y":1}]}');
  });
  it('no whitespace', () => {
    expect(canonicalJson({ a: 1 })).not.toMatch(/\s/);
  });
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-sign`
Expected: FAIL — import не резолвится.

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

```js
// tools/receipt-sign.mjs
#!/usr/bin/env node
/**
 * receipt-sign — подпись/проверка расписок и хеш-первооснова Журнала.
 * canonicalJson: стабильная сериализация (сорт ключей, без пробелов) — иначе
 * HMAC/хеш зависят от порядка ключей. signPayload/verifyReceipt — HMAC-SHA256.
 */
import { createHmac, timingSafeEqual } from 'node:crypto';

/** Стабильная сериализация: ключи объектов сортируются рекурсивно, без пробелов. */
export function canonicalJson(value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
  const keys = Object.keys(value).sort();
  return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
}
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-sign`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/receipt-sign.mjs tools/receipt-sign.test.mjs
git commit -m "feat(m1): canonicalJson — stable serialization for sign/chain"
```

---

## Task 3: Подпись и проверка расписок (`receipt-sign.mjs` — часть 2)

HMAC-SHA256 над `canonicalJson(payload)`. Без ключа `signPayload`→`null`; `verifyReceipt` неподписанной/чужой расписки → `false` (fail-closed). Сравнение подписи — постоянное по времени (`timingSafeEqual`).

**Files:**
- Modify: `tools/receipt-sign.mjs`
- Test: `tools/receipt-sign.test.mjs` (дописать)

- [ ] **Step 1: Дописать падающие тесты**

```js
// tools/receipt-sign.test.mjs — добавить ниже describe('canonicalJson'…)
import { signPayload, verifyReceipt } from './receipt-sign.mjs';

describe('signPayload / verifyReceipt', () => {
  const KEY = 'test-secret-key-123';

  it('signPayload returns a 64-char hex HMAC', () => {
    const sig = signPayload({ a: 1 }, KEY);
    expect(sig).toMatch(/^[0-9a-f]{64}$/);
  });
  it('signPayload returns null without a key', () => {
    expect(signPayload({ a: 1 }, null)).toBe(null);
    expect(signPayload({ a: 1 }, '')).toBe(null);
  });
  it('verifyReceipt true for a correctly signed record', () => {
    const rec = { kind: 'x', n: 5 };
    const sig = signPayload(rec, KEY);
    expect(verifyReceipt({ ...rec, sig }, KEY)).toBe(true);
  });
  it('verifyReceipt false when payload tampered after signing', () => {
    const rec = { kind: 'x', n: 5 };
    const sig = signPayload(rec, KEY);
    expect(verifyReceipt({ ...rec, n: 6, sig }, KEY)).toBe(false);
  });
  it('verifyReceipt false for a wrong key', () => {
    const rec = { kind: 'x', n: 5 };
    const sig = signPayload(rec, KEY);
    expect(verifyReceipt({ ...rec, sig }, 'other-key')).toBe(false);
  });
  it('verifyReceipt false for an unsigned record (no key → no trust)', () => {
    expect(verifyReceipt({ kind: 'x', n: 5 }, KEY)).toBe(false);
  });
  it('verifyReceipt false when verifying key is null', () => {
    const sig = signPayload({ a: 1 }, KEY);
    expect(verifyReceipt({ a: 1, sig }, null)).toBe(false);
  });
});
```

- [ ] **Step 2: Прогнать — новые падают**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-sign`
Expected: FAIL — `signPayload`/`verifyReceipt` не экспортированы.

- [ ] **Step 3: Реализация (дописать в `receipt-sign.mjs`)**

```js
// tools/receipt-sign.mjs — добавить ниже canonicalJson

/** HMAC-SHA256(hex) над canonicalJson(payload). Без ключа → null. */
export function signPayload(payload, key) {
  if (!key) return null;
  return createHmac('sha256', String(key)).update(canonicalJson(payload)).digest('hex');
}

/**
 * Проверка расписки: record несёт поле `sig`; пересчитываем подпись над
 * record-без-sig и сверяем постоянным по времени сравнением.
 * Неподписанная (нет sig) или без ключа → false (fail-closed).
 */
export function verifyReceipt(record, key) {
  if (!key || !record || typeof record !== 'object') return false;
  const { sig, ...payload } = record;
  if (typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false;
  const expected = signPayload(payload, key);
  if (!expected) return false;
  const a = Buffer.from(sig, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs receipt-sign`
Expected: PASS (4 + 7 = 11 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/receipt-sign.mjs tools/receipt-sign.test.mjs
git commit -m "feat(m1): signPayload/verifyReceipt — HMAC receipts, fail-closed unsigned"
```

---

## Task 4: Ядро Журнала S1 — хеш-цепочка (`action-journal.mjs` — часть 1)

Append-only цепочка: `chain_hash = sha256(prev_hash + canonicalJson(payload))`. Правка любой записи задним числом ломает все последующие хеши. Голова цепи (последний `chain_hash`) HMAC-подписана → оптовая перезапись истории требует ключ-вне-меня.

**Files:**
- Create: `tools/action-journal.mjs`
- Test: `tools/action-journal.test.mjs`

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

```js
// tools/action-journal.test.mjs
import { describe, it, expect } from 'vitest';
import { GENESIS_HASH, computeEntryHash, appendEntry, verifyChain } from './action-journal.mjs';

const KEY = 'journal-test-key';

describe('action-journal core', () => {
  it('GENESIS_HASH is 64 zeros', () => {
    expect(GENESIS_HASH).toBe('0'.repeat(64));
  });
  it('computeEntryHash is deterministic and 64-char hex', () => {
    const h = computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' });
    expect(h).toMatch(/^[0-9a-f]{64}$/);
    expect(computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' })).toBe(h);
  });
  it('appendEntry links to previous head and signs the head', () => {
    const e1 = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1000 });
    expect(e1.entries).toHaveLength(1);
    expect(e1.entries[0].seq).toBe(1);
    expect(e1.entries[0].prev_hash).toBe(GENESIS_HASH);
    expect(e1.headSig).toMatch(/^[0-9a-f]{64}$/);

    const e2 = appendEntry(e1.entries, { action: 'B' }, { key: KEY, nowMs: 2000 });
    expect(e2.entries).toHaveLength(2);
    expect(e2.entries[1].prev_hash).toBe(e1.entries[0].chain_hash);
    expect(e2.entries[1].seq).toBe(2);
  });
  it('verifyChain ok for an intact, signed chain', () => {
    const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
    const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
    expect(verifyChain(b.entries, b.headSig, { key: KEY })).toEqual({ ok: true, brokenAt: null });
  });
  it('verifyChain detects a tampered entry (rewrite history)', () => {
    const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
    const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
    const tampered = b.entries.map((e, i) => i === 0 ? { ...e, payload: { action: 'EVIL' } } : e);
    const r = verifyChain(tampered, b.headSig, { key: KEY });
    expect(r.ok).toBe(false);
    expect(r.brokenAt).toBe(1); // seq of first broken entry
  });
  it('verifyChain detects wholesale rewrite (recomputed chain, forged head) without key', () => {
    // Attacker recomputes a fully-consistent chain but cannot forge the HMAC head sig.
    const a = appendEntry([], { action: 'EVIL' }, { key: 'attacker-key', nowMs: 1 });
    const r = verifyChain(a.entries, a.headSig, { key: KEY }); // verifier uses the real key
    expect(r.ok).toBe(false);
  });
  it('verifyChain false when head signature missing', () => {
    const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
    expect(verifyChain(a.entries, null, { key: KEY }).ok).toBe(false);
  });
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs action-journal`
Expected: FAIL — import не резолвится.

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

```js
// tools/action-journal.mjs
#!/usr/bin/env node
/**
 * action-journal — Журнал S1 (несущий №1): append-only хеш-цепочка действий.
 * chain_hash = sha256(prev_hash + canonicalJson(payload)). Правка записи задним
 * числом ломает последующие хеши; голова цепи HMAC-подписана (оптовую перезапись
 * нельзя без ключа). Идея цепи — из audit_chain_hash() (db/schema.sql:75).
 * Лог-до-действия и «нет записи → нет действия» — ENFORCEMENT Машины 2 (верховный
 * хук читает журнал); здесь — примитив записи + tamper-evidence + подпись головы.
 */
import { createHash } from 'node:crypto';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';

export const GENESIS_HASH = '0'.repeat(64);

/** chain_hash = sha256(prev_hash + canonicalJson(payload)). */
export function computeEntryHash(prevHash, payload) {
  return createHash('sha256').update(String(prevHash) + canonicalJson(payload)).digest('hex');
}

/**
 * Добавить запись к цепочке (чисто; персист — отдельно в journalAppend).
 * @returns {{entries: object[], headSig: string|null}}
 */
export function appendEntry(entries, payload, { key, nowMs }) {
  const prev = entries.length ? entries[entries.length - 1] : null;
  const prevHash = prev ? prev.chain_hash : GENESIS_HASH;
  const seq = (prev ? prev.seq : 0) + 1;
  const entry = {
    seq,
    ts: typeof nowMs === 'number' ? nowMs : Date.now(),
    payload,
    prev_hash: prevHash,
    chain_hash: computeEntryHash(prevHash, payload),
  };
  const next = [...entries, entry];
  const headSig = signHead(entry.seq, entry.chain_hash, key);
  return { entries: next, headSig };
}

/** Подпись головы: HMAC над {seq, head_hash} (через verifyReceipt-совместимую форму). */
export function signHead(seq, headHash, key) {
  return signPayload({ seq, head_hash: headHash }, key);
}

/**
 * Проверка цепочки: (1) каждый chain_hash пересчитывается из prev_hash+payload;
 * (2) prev_hash каждой записи = chain_hash предыдущей; (3) подпись головы валидна.
 * @returns {{ok: boolean, brokenAt: number|null}} brokenAt = seq первой битой записи (или null).
 */
export function verifyChain(entries, headSig, { key }) {
  if (!Array.isArray(entries) || entries.length === 0) return { ok: false, brokenAt: null };
  let prevHash = GENESIS_HASH;
  for (const e of entries) {
    if (e.prev_hash !== prevHash) return { ok: false, brokenAt: e.seq };
    if (computeEntryHash(prevHash, e.payload) !== e.chain_hash) return { ok: false, brokenAt: e.seq };
    prevHash = e.chain_hash;
  }
  const head = entries[entries.length - 1];
  const headOk = verifyReceipt({ seq: head.seq, head_hash: head.chain_hash, sig: headSig }, key);
  if (!headOk) return { ok: false, brokenAt: head.seq };
  return { ok: true, brokenAt: null };
}
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs action-journal`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/action-journal.mjs tools/action-journal.test.mjs
git commit -m "feat(m1): action-journal core — hash chain + HMAC head anchor"
```

---

## Task 5: Персист Журнала (`action-journal.mjs` — часть 2)

`journalAppend` читает существующий JSONL, добавляет запись, пишет файл цепочки + файл подписи головы через инъектируемый `fs`. Запись — через Node `fs` (не Write-tool), путь в `~/.claude/runtime/`.

**Files:**
- Modify: `tools/action-journal.mjs`
- Test: `tools/action-journal.test.mjs` (дописать)

- [ ] **Step 1: Дописать падающие тесты**

```js
// tools/action-journal.test.mjs — добавить ниже
import { journalAppend, loadJournal } from './action-journal.mjs';

function memFs() {
  const store = new Map();
  return {
    store,
    readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
    writeFileSync: (p, data) => { store.set(String(p), String(data)); },
    appendFileSync: (p, data) => { store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(data)); },
    existsSync: (p) => store.has(String(p)),
  };
}

describe('journalAppend persistence', () => {
  const KEY = 'journal-test-key';
  const opts = (fs) => ({ key: KEY, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });

  it('writes a JSONL line and a head-sig file', () => {
    const fs = memFs();
    const r = journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
    expect(r.entry.seq).toBe(1);
    expect(fs.store.get('/rt/action-journal-S1.jsonl')).toContain('"action":"A"');
    expect(fs.store.get('/rt/action-journal-S1.head')).toBe(r.headSig);
  });

  it('appends a second entry linked to the first', () => {
    const fs = memFs();
    journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
    const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) });
    expect(r2.entry.seq).toBe(2);
    const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
    expect(loaded.entries).toHaveLength(2);
    expect(loaded.entries[1].prev_hash).toBe(loaded.entries[0].chain_hash);
  });

  it('loadJournal returns empty for a missing file', () => {
    const fs = memFs();
    expect(loadJournal({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).toEqual({ entries: [], headSig: null });
  });

  it('round-trip verifyChain on persisted-then-loaded journal', () => {
    const fs = memFs();
    journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
    const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) });
    const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
    expect(verifyChain(loaded.entries, r2.headSig, { key: KEY }).ok).toBe(true);
  });
});
```

- [ ] **Step 2: Прогнать — новые падают**

Run: `npx vitest run --root app --config vitest.config.tools.mjs action-journal`
Expected: FAIL — `journalAppend`/`loadJournal` не экспортированы.

- [ ] **Step 3: Реализация (дописать в `action-journal.mjs`)**

```js
// tools/action-journal.mjs — добавить ниже verifyChain
import fsDefault from 'node:fs';

function paths(runtimeDir, sessionId) {
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return {
    jsonl: `${runtimeDir}${sep}action-journal-${sessionId}.jsonl`,
    head: `${runtimeDir}${sep}action-journal-${sessionId}.head`,
  };
}

/** Прочитать цепочку из JSONL + подпись головы. Нет файла → пусто. */
export function loadJournal({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  const { jsonl, head } = paths(runtimeDir, sessionId);
  let entries = [];
  try {
    const raw = fsImpl.readFileSync(jsonl, 'utf8');
    entries = String(raw).split('\n').filter(Boolean).map((l) => JSON.parse(l));
  } catch (e) {
    if (e && e.code === 'ENOENT') return { entries: [], headSig: null };
    throw e;
  }
  let headSig = null;
  try { headSig = String(fsImpl.readFileSync(head, 'utf8')).trim() || null; } catch { headSig = null; }
  return { entries, headSig };
}

/**
 * Добавить действие в журнал сессии (персист через Node fs — НЕ Write-tool).
 * @returns {{entry: object, headSig: string|null}}
 */
export function journalAppend({ payload, key, sessionId, runtimeDir, nowMs, fsImpl = fsDefault }) {
  const { entries } = loadJournal({ sessionId, runtimeDir, fsImpl });
  const { entries: next, headSig } = appendEntry(entries, payload, { key, nowMs });
  const entry = next[next.length - 1];
  const { jsonl, head } = paths(runtimeDir, sessionId);
  fsImpl.appendFileSync(jsonl, JSON.stringify(entry) + '\n');
  fsImpl.writeFileSync(head, String(headSig ?? ''));
  return { entry, headSig };
}
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs action-journal`
Expected: PASS (7 + 4 = 11 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/action-journal.mjs tools/action-journal.test.mjs
git commit -m "feat(m1): journalAppend/loadJournal — JSONL persistence via injected fs"
```

---

## Task 6: NFC-нормализация пути (`path-normalization.mjs`, P10-b)

Unicode-эвейжн: путь, записанный в декомпозированной форме (NFD), не совпадал бы с regex по композированной форме. Добавляем `.normalize('NFC')`.

**Files:**
- Modify: `tools/path-normalization.mjs`
- Test: `tools/path-normalization.test.mjs` (дописать)

- [ ] **Step 1: Дописать падающий тест**

```js
// tools/path-normalization.test.mjs — добавить (импорт pathNormalize уже есть в файле; если нет — добавить)
import { pathNormalize } from './path-normalization.mjs';

describe('pathNormalize NFC (P10-b)', () => {
  it('returns NFC-normalized output for decomposed input', () => {
    // 'é' decomposed = 'e' + U+0301; composed = U+00E9. Both must normalize equal.
    const decomposed = '/tmp/café/x';  // café (NFD)
    const composed = '/tmp/café/x';       // café (NFC)
    const optsPosix = { platform: 'linux', homedir: '/home/u', realpath: (p) => p, resolve: (p) => p };
    expect(pathNormalize(decomposed, optsPosix)).toBe(pathNormalize(composed, optsPosix));
    expect(pathNormalize(decomposed, optsPosix)).toBe(pathNormalize(composed, optsPosix).normalize('NFC'));
  });
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs path-normalization`
Expected: FAIL — выход не NFC-нормализован (декомпозированный ≠ композированный).

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

В `tools/path-normalization.mjs`, в функции `pathNormalize`, заменить финальный `return`:

```js
  // было:
  // return caseFold(real, platform).split('\\').join('/');
  // стало (P10-b — NFC, чтобы декомпозированный Unicode не обходил regex защиты):
  return caseFold(real, platform).split('\\').join('/').normalize('NFC');
```

- [ ] **Step 4: Прогнать — зелено + регрессия**

Run: `npx vitest run --root app --config vitest.config.tools.mjs path-normalization`
Expected: PASS (новый тест + все прежние path-normalization тесты).

- [ ] **Step 5: Commit**

```bash
git add tools/path-normalization.mjs tools/path-normalization.test.mjs
git commit -m "feat(m1): pathNormalize NFC normalization (P10-b unicode evasion)"
```

---

## Task 7: runtime-write-deny — все каналы записи (`enforce-runtime-write-deny.mjs`, P10-a)

Сейчас блокируются только 4 именованных Write-tool'а. Обобщаем: блокируем ЛЮБОЙ инструмент, чей ввод несёт path-поле, нормализующееся в `~/.claude/runtime` (ловит MCP-писатели). Шелл-каналы (`echo>`/`cp`/`mv`) — отдельно под Bash/PowerShell-гейтами (М6), здесь не дублируем.

**Files:**
- Modify: `tools/enforce-runtime-write-deny.mjs`
- Test: `tools/enforce-runtime-write-deny.test.mjs` (дописать/обновить)

- [ ] **Step 1: Обновить импорт и дописать падающие тесты**

Заменить в шапке теста `import { decide } from './enforce-runtime-write-deny.mjs';` на:
```js
import { decide, decideFromEvent } from './enforce-runtime-write-deny.mjs';
```

Добавить блок:
```js
// tools/enforce-runtime-write-deny.test.mjs — добавить
describe('runtime-write-deny — any path-bearing tool (P10-a)', () => {
  it('blocks an unknown MCP write tool whose input path is in runtime', () => {
    const r = decideFromEvent({ tool_name: 'mcp__fs__write_file', tool_input: { file_path: `${HOME_FWD}/.claude/runtime/x.jsonl` } });
    expect(r.block).toBe(true);
  });
  it('blocks a tool that carries the path under a `path` field', () => {
    const r = decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { path: `${HOME_FWD}/.claude/runtime/y.json` } });
    expect(r.block).toBe(true);
  });
  it('does NOT block a path-bearing tool when the path is a normal project file', () => {
    const r = decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { path: `${HOME_FWD}/project/x.mjs` } });
    expect(r.block).toBe(false);
  });
  it('ignores tools with no path field', () => {
    expect(decideFromEvent({ tool_name: 'Bash', tool_input: { command: 'ls' } }).block).toBe(false);
  });
});
```

Заменить старый тест `it('ignores non-write tools', …)` на:
```js
  it('blocks by path regardless of tool name when path is in runtime (P10-a)', () => {
    expect(decide({ filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(true);
  });
```
(Прочие старые тесты, зовущие `decide({ toolName:'Write', filePath })`, оставить — лишний `toolName` игнорируется, путь в runtime → блок, они продолжат проходить.)

- [ ] **Step 2: Прогнать — новые/обновлённые падают**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-runtime-write-deny`
Expected: FAIL — `decideFromEvent` не экспортирован; именованный MCP-tool не блокируется.

- [ ] **Step 3: Реализация (`enforce-runtime-write-deny.mjs`)**

Удалить `const WRITE_TOOLS = …`. Заменить `decide` на path-ориентированную проверку (имя инструмента больше не ограничивает) и добавить `extractPath` + `decideFromEvent`:

```js
// RUNTIME_RE оставить как есть.

const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file'];

/** Извлечь предполагаемый путь записи из tool_input по известным полям. '' если нет. */
export function extractPath(toolInput) {
  if (!toolInput || typeof toolInput !== 'object') return '';
  for (const f of PATH_FIELDS) {
    if (typeof toolInput[f] === 'string' && toolInput[f]) return toolInput[f];
  }
  return '';
}

/**
 * Pure decision по пути (toolName-агностично). Блок ТОЛЬКО при подтверждённом
 * совпадении с ~/.claude/runtime. Нормализация бросила → fail-open.
 * @returns {{block:boolean, reason?:string}}
 */
export function decide({ filePath, normalizeImpl = pathNormalize }) {
  const fp = String(filePath || '');
  if (!fp) return { block: false };
  let norm;
  try { norm = normalizeImpl(fp); } catch { return { block: false }; }
  if (RUNTIME_RE.test(String(norm || ''))) {
    return {
      block: true,
      reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (receipts / journal head). Hooks write it via Node fs, not file-writing tools.`,
    };
  }
  return { block: false };
}

/** Decision из полного события: извлекает путь из любого инструмента (P10-a). */
export function decideFromEvent(event) {
  return decide({ filePath: extractPath(event && event.tool_input) });
}
```

Заменить `main()`:

```js
async function main() {
  try {
    const event = parseEventJson(await readStdin());
    const r = decideFromEvent(event);
    exitDecision({ block: r.block, message: r.reason });
  } catch {
    exitDecision({ block: false });
  }
}
```

- [ ] **Step 4: Прогнать — зелено**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-runtime-write-deny`
Expected: PASS (обновлённые + новые).

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-runtime-write-deny.mjs tools/enforce-runtime-write-deny.test.mjs
git commit -m "feat(m1): runtime-write-deny — block any path-bearing tool (P10-a all channels)"
```

---

## Task 8: read-path-deny — фиксация over-block-fix регрессией

Машина 1 оставляет read-path-deny как есть, но ЗАКРЕПЛЯЕТ поведением (CLAUDE.md v2.43): транскрипты `.jsonl` / `~/.claude/runtime` / `settings.json` / `.env` блокируются на чтение; CLAUDE.md / нормативка / `memory/` — читаются. Добавляем regression-тесты, чтобы будущая правка не вернула over-block.

**Files:**
- Modify: `tools/enforce-read-path-deny.test.mjs` (дописать; код хука НЕ трогаем)

- [ ] **Step 1: Прочитать текущий хук и его decide-контракт**

Read: `tools/enforce-read-path-deny.mjs` — выяснить имя экспортируемой pure-функции и форму входа (`{toolName, filePath}` или `{filePath}`). Тесты ниже используют плейсхолдер `decide` — **привести вызовы к фактической сигнатуре из файла** (единственное место сверки с реальностью перед написанием).

- [ ] **Step 2: Написать regression-тесты (lock-поведение)**

```js
// tools/enforce-read-path-deny.test.mjs — добавить блок (сигнатуру взять из Step 1)
import { homedir } from 'node:os';
const H = homedir().replace(/\\/g, '/');

describe('read-path-deny — over-block fix lock (CLAUDE.md v2.43)', () => {
  const blocks = (p) => decide({ toolName: 'Read', filePath: p }).block; // ← сверить форму со Step 1

  it('blocks cross-session transcripts (.jsonl) under .claude/projects', () => {
    expect(blocks(`${H}/.claude/projects/abc/session.jsonl`)).toBe(true);
  });
  it('blocks ~/.claude/runtime files', () => {
    expect(blocks(`${H}/.claude/runtime/askuser-decisions-S.jsonl`)).toBe(true);
  });
  it('blocks settings.json and .env', () => {
    expect(blocks(`${H}/.claude/settings.json`)).toBe(true);
    expect(blocks(`/proj/app/.env`)).toBe(true);
  });
  it('does NOT block CLAUDE.md / Pravila / memory index (over-block fix)', () => {
    expect(blocks(`/proj/CLAUDE.md`)).toBe(false);
    expect(blocks(`/proj/docs/Pravila_raboty_Claude_v1_1.md`)).toBe(false);
    expect(blocks(`${H}/.claude/projects/x/memory/MEMORY.md`)).toBe(false);
  });
});
```

- [ ] **Step 3: Прогнать — должны СРАЗУ пройти (поведение уже верное)**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-read-path-deny`
Expected: PASS. Если падает «does NOT block CLAUDE.md/memory» — over-block-fix регрессировал в текущем коде: **остановиться, доложить владельцу** (реальная поломка защиты, чинить отдельным решением, не в этом плане).

- [ ] **Step 4: Commit**

```bash
git add tools/enforce-read-path-deny.test.mjs
git commit -m "test(m1): lock read-path-deny over-block fix (CLAUDE.md v2.43)"
```

---

## Task 9: Подписанные расписки AskUser (`askuser-answer-parser.mjs`, P10-c)

Расписка владельца (одобрение действия) подписывается HMAC при записи и проверяется при чтении. Неподписанная/подделанная расписка → невалидна. Связывает приёмщик AskUser с подписью (Task 1–3).

**Files:**
- Modify: `tools/askuser-answer-parser.mjs`
- Test: `tools/askuser-answer-parser.test.mjs` (дописать)

- [ ] **Step 1: Дописать падающие тесты**

```js
// tools/askuser-answer-parser.test.mjs — добавить
import { signApprovalRecord, verifyApprovalRecord, buildApprovalRecord } from './askuser-answer-parser.mjs';

describe('signed approval records (P10-c)', () => {
  const KEY = 'receipt-key-xyz';
  it('signApprovalRecord attaches a valid sig', () => {
    const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
    const signed = signApprovalRecord(rec, KEY);
    expect(signed.sig).toMatch(/^[0-9a-f]{64}$/);
    expect(verifyApprovalRecord(signed, KEY)).toBe(true);
  });
  it('verifyApprovalRecord false when the approved pattern is tampered', () => {
    const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
    const signed = signApprovalRecord(rec, KEY);
    expect(verifyApprovalRecord({ ...signed, approved_action_pattern: 'git push --force' }, KEY)).toBe(false);
  });
  it('verifyApprovalRecord false for an unsigned record (forged by controller)', () => {
    const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
    expect(verifyApprovalRecord(rec, KEY)).toBe(false);
  });
  it('signApprovalRecord returns sig:null when no key (fail-closed downstream)', () => {
    const rec = buildApprovalRecord({ kind: 'x', pattern: 'git status', sessionId: 'S', nowMs: 1 });
    const signed = signApprovalRecord(rec, null);
    expect(signed.sig).toBe(null);
    expect(verifyApprovalRecord(signed, KEY)).toBe(false);
  });
});
```

- [ ] **Step 2: Прогнать — новые падают**

Run: `npx vitest run --root app --config vitest.config.tools.mjs askuser-answer-parser`
Expected: FAIL — `signApprovalRecord`/`verifyApprovalRecord` не экспортированы.

- [ ] **Step 3: Реализация (дописать в `askuser-answer-parser.mjs`)**

В шапку импортов добавить:
```js
import { signPayload, verifyReceipt } from './receipt-sign.mjs';
```
В конец файла:
```js
/**
 * Подписать расписку (P10-c). Возвращает копию record с полем sig
 * (HMAC над record без sig). Без ключа — sig:null (downstream verify → false).
 */
export function signApprovalRecord(record, key) {
  return { ...record, sig: signPayload(record, key) };
}

/** Проверить подпись расписки. Неподписанная/подделанная/без ключа → false. */
export function verifyApprovalRecord(record, key) {
  return verifyReceipt(record, key);
}
```

- [ ] **Step 4: Прогнать — зелено + регрессия askuser**

Run: `npx vitest run --root app --config vitest.config.tools.mjs askuser-answer-parser`
Expected: PASS (новые + все прежние askuser-тесты).

- [ ] **Step 5: Commit**

```bash
git add tools/askuser-answer-parser.mjs tools/askuser-answer-parser.test.mjs
git commit -m "feat(m1): signed askuser approval records (P10-c HMAC receipts)"
```

---

## Task 10: Интеграционный self-verify Фундамента + полная регрессия

Один сквозной тест-инвариант, доказывающий обещания Машины 1 разом, + полный прогон tools-only.

**Files:**
- Create: `tools/m1-foundation-invariants.test.mjs`

- [ ] **Step 1: Написать инвариант-тест**

```js
// tools/m1-foundation-invariants.test.mjs
// Машина 1 — сквозные инварианты фундамента (мастер-карта §«МАШИНА 1»).
import { describe, it, expect } from 'vitest';
import { journalAppend, loadJournal, verifyChain } from './action-journal.mjs';
import { signApprovalRecord, verifyApprovalRecord, buildApprovalRecord } from './askuser-answer-parser.mjs';
import { decideFromEvent } from './enforce-runtime-write-deny.mjs';
import { homedir } from 'node:os';

function memFs() {
  const store = new Map();
  return {
    store,
    readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
    writeFileSync: (p, d) => store.set(String(p), String(d)),
    appendFileSync: (p, d) => store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(d)),
    existsSync: (p) => store.has(String(p)),
  };
}
const HOME_FWD = homedir().replace(/\\/g, '/');
const KEY = 'foundation-key';

describe('Машина 1 — инварианты фундамента', () => {
  it('журнал: лог-до-действия пишется, и подменить запись задним числом нельзя', () => {
    const fs = memFs();
    journalAppend({ payload: { action: 'Edit', file: 'a.mjs' }, key: KEY, sessionId: 'S', runtimeDir: '/rt', nowMs: 1, fsImpl: fs });
    const r2 = journalAppend({ payload: { action: 'Bash', cmd: 'git commit' }, key: KEY, sessionId: 'S', runtimeDir: '/rt', nowMs: 2, fsImpl: fs });
    const loaded = loadJournal({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
    expect(verifyChain(loaded.entries, r2.headSig, { key: KEY }).ok).toBe(true);
    const tampered = loaded.entries.map((e, i) => i === 0 ? { ...e, payload: { action: 'NOTHING' } } : e);
    expect(verifyChain(tampered, r2.headSig, { key: KEY }).ok).toBe(false);
  });

  it('расписка: подделать без ключа нельзя', () => {
    const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git push --force', sessionId: 'S', nowMs: 1 });
    expect(verifyApprovalRecord(rec, KEY)).toBe(false);
    expect(verifyApprovalRecord(signApprovalRecord(rec, KEY), KEY)).toBe(true);
  });

  it('runtime защищён от записи любым инструментом', () => {
    expect(decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { path: `${HOME_FWD}/.claude/runtime/x` } }).block).toBe(true);
    expect(decideFromEvent({ tool_name: 'Write', tool_input: { file_path: `${HOME_FWD}/proj/x.mjs` } }).block).toBe(false);
  });
});
```

- [ ] **Step 2: Прогнать инвариант-тест**

Run: `npx vitest run --root app --config vitest.config.tools.mjs m1-foundation-invariants`
Expected: PASS (3 tests).

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

Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: PASS — все прежние tools-тесты + новые Машины 1. Зафиксировать число GREEN (база ~1996 из дизайн-заметок + новые ≈ +40). Любой упавший прежний тест → разобрать ДО коммита.

- [ ] **Step 4: Commit**

```bash
git add tools/m1-foundation-invariants.test.mjs
git commit -m "test(m1): foundation cross-invariants (journal tamper / unsigned receipt / runtime deny)"
```

---

## Self-Review (сверка плана против Машины 1 мастер-карты)

**1. Покрытие спецификации (Машина 1 «Что входит»):**
- ✅ Журнал S1 (append-only хеш-цепочка, лог-до-действия-примитив, tamper-evidence) → Task 4–5. *Enforcement «нет записи → нет действия» — Машина 2 (верховный хук); здесь только примитив + verify — отмечено в шапке `action-journal.mjs`.*
- ✅ Подпись расписок HMAC из OS-keychain → Task 1 (ключ) + Task 2–3 (подпись) + Task 9 (расписки AskUser).
- ✅ runtime-write-deny + все-каналы (P10-a) + нормализация пути (P10-b NFC) → Task 6–7.
- ✅ read-path-deny + over-block-fix сохранён → Task 8 (закреплён регрессией).
- ✅ askuser-answer-parser как честный приёмщик + подпись → Task 9.
- ✅ Инвариант М1 (без любого из {journal, signed receipts, runtime-deny, read-deny, askuser-parser} судью подделать) → Task 10 проверяет 3 ключевых; read-deny — Task 8.

**2. Сканирование на заглушки:** код-блоки полные (нет TBD/«добавить обработку»). Единственная сверка-с-реальностью отложена явно и узко — Task 8 Step 1 (прочитать фактическую сигнатуру `decide` в `enforce-read-path-deny.mjs` перед написанием теста), т.к. её точную форму не зафиксировали при заземлении. Это не заглушка-код, а одно honest-reading-действие.

**3. Согласованность типов/имён:**
- `canonicalJson` (Task 2) ← используется `signPayload` (Task 3) и `computeEntryHash` (Task 4) — один импорт из `receipt-sign.mjs`.
- `signPayload`/`verifyReceipt` (Task 3) ← `action-journal` голова (Task 4) и `askuser` расписки (Task 9) — единый контракт `{...payload, sig}`.
- `appendEntry` возвращает `{entries, headSig}` (Task 4) — так же читается в Task 5 и Task 10.
- `decide`/`decideFromEvent`/`extractPath` (Task 7) — `decideFromEvent` зовёт `decide({filePath})`; `main` зовёт `decideFromEvent`.
- `resolveReceiptKey` (Task 1) — провайдер ключа для будущих потребителей (Машины 2/4; в самой М1 ключ инъектируется в тестах как строка).

**Зазор (осознанный, не дыра):** `resolveReceiptKey` в Машине 1 нигде не вызывается в проде (журнал/расписки получают `key` параметром) — он провайдер для будущих хуков-потребителей (верховный хук М2, судья М4). Правильная граница слоёв: М1 даёт примитивы + провайдер ключа, потребление — выше. Зафиксировано, чтобы при ревью не приняли за «мёртвый код».

---

## СЛЕДУЮЩИЙ ШАГ

План Машины 1 готов. Исполнение — `superpowers:subagent-driven-development` (свежий субагент на задачу + двухстадийное ревью) ИЛИ `superpowers:executing-plans` (встык в этой сессии с чекпойнтами). По завершении М1 и полной регрессии — детальный план Машины 2 (Замок плана + Верховный хук), который ПОТРЕБЛЯЕТ журнал и расписки этого фундамента.

---

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

Адверсари-сверка построчно (10 задач плана + несущие №1/№5 + P10-a/b/c). **Все 10 задач собраны точно, полная регрессия 2222 GREEN, внутренняя логика модулей цела.** Хвосты — это граница слоёв (A) + непреднамеренные зазоры аудита (B).

### A. Сознательно отложено (граница слоёв — НЕ дыры)
- **A1.** Enforcement «нет записи → нет действия» → **Машина 2** (верховный хук читает журнал). M1 = только примитив записи + verify (записано в шапке `action-journal.mjs`).
- **A2.** Реальное потребление журнала/расписок (кто пишет ДО действия, кто читает расписки) → **Машины 2/4**. M1 — библиотека-фундамент.
- **A3.** `resolveReceiptKey` в проде M1 не вызывается — провайдер ключа для потребителей M2/M4 (Self-Review плана: осознанный зазор, не мёртвый код).
- **A4.** HMAC-ключ расписок провижинит **владелец** (keychain `router-mentor-receipts` / env `ROUTER_MENTOR_RECEIPT_KEY`); без ключа подпись=null, verify=false (fail-closed). Owner-шаг.

### B. Замечания аудита (непреднамеренные — план их не признавал)
- **B1** `[owner-шаг]` — **P10-a живёт наполовину.** `decide` toolName-агностичный (ловит любой path-bearing tool), но живой хук-matcher узкий (`Edit|Write|MultiEdit|NotebookEdit`, заголовок файла) → MCP-писатель `mcp__fs__write` хук не вызовет. Цель «ловить MCP» в проде не работает, пока владелец не расширит matcher в `.claude/settings.json` (или `*`). Код готов.
- **B2** `[ИСПРАВЛЕНО 2026-06-05]` — `ts`/`seq` промежуточных записей журнала были ВНЕ `chain_hash` → подделываемы без детекта. Фикс: `chain_hash` теперь над `{seq, ts, payload}` (подмена ts/seq ломает цепь).
- **B3** `[→ Машина 4, контракт K6]` — откат журнала (атакующий кладёт старую валидно-подписанную пару `.jsonl`+`.head`) `verifyChain` не ловит. **ИСПРАВЛЕНО размещение (2026-06-05):** изначально ошибочно помечено «→ Машина 2», но аудит M2 показал, что M2 этого не строит И не должен (зарубка в runtime откатится вместе с журналом = призрак). Монотонную зарубку (high-water-mark) держит **судья (Машина 4, контракт K6)** ВНЕ откатываемого набора. Риск низкий и условный: откат возможен лишь после пробития пола `runtime-write-deny`; подделка нового состояния закрыта печатью.
- **B4** `[ИСПРАВЛЕНО 2026-06-05]` — `extractPath` знал 4 поля → MCP с полем `filename`/`uri`/`destination` проскальзывал. Фикс: список путь-полей расширен.

### Статус фикс-сета
- **Исправлено кодом (TDD):** B2 (`action-journal` — seq+ts в хеше), B4 (`enforce-runtime-write-deny` — расширены path-поля).
- **Owner-шаг:** B1 (расширить matcher хука в settings.json — иначе P10-a не ловит MCP вживую).
- **Требование к Машине 4 (контракт K6):** B3 (анти-rollback монотонный high-water-mark вне отката; изначально ошибочно адресовано Машине 2 — исправлено 2026-06-05).
- **Граница слоёв (не трогать в M1):** A1–A4.
