# Доска «кто на посту» — live-источник escape/блоков (D-3) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (инлайн; субагенты запрещены владельцем). Per-task: audit-context → TDD → systematic-debugging(на красный) → verification. Гейт закрытия: sharp-edges → variant-analysis → regression(tools-only ≥3449+2skip) → verification. Steps — `- [ ]`.

**Goal:** Наполнить доску `## Кто на посту` в STATUS.md реальными недавними escape владельца и блоками машин М1–М6 (сейчас хардкод `[]`/`[]`).

**Architecture:** Presentation-слой (`computeGuardBoardBlock`) уже рендерит `{ts,action,reason}`. Добавляем: (1) новый best-effort журнал блоков `guard-blocks-<sess>.jsonl` + логгер, в который пишут машинные хуки при РЕШЁННОМ блоке (не на infra-fail-CLOSE); (2) board-читатели `loadRecentBlocks`/`loadRecentEscapes` (скан всех session-файлов в runtime, окно+сортировка+cap); (3) проводка читателей в CLI `status-md-generator`. Логгер fail-quiet (try/catch, Node fs) — НИКОГДА не влияет на block-решение (вызывается ПОСЛЕ решения).

**Tech Stack:** Node ESM, vitest (tools-config). Без новых зависимостей. Логгер — Node fs append (как `logVerdictLine`/`logViolation`; runtime-write-deny не мешает — это процесс хука, не Write-tool).

**Несущая зависимость (P2-1 аналог):** журнал блоков достоверен только при зарегистрированном поле-страже runtime (Фаза 8). До флипа — данных нет (хуки не зарегистрированы), доска покажет 0/0; это корректно.

---

## File Structure
- **Create:** `tools/guard-block-log.mjs` — `buildGuardBlockEntry` (pure) + `logGuardBlock` (I/O, fail-quiet) + `loadRecentBlocks` + `loadRecentEscapes` (board-читатели).
- **Create:** `tools/guard-block-log.test.mjs` — юнит-тесты модуля (memFs).
- **Modify (проводка логгера, 9 машинных хуков):** `enforce-floor` · `enforce-supreme-gate` · `enforce-judge-gate` · `enforce-snapshot` · `enforce-read-path-deny` · `enforce-mcp-classification` · `enforce-normative-content-rules` · `enforce-verify-gate` · `enforce-criterion-gate` — import + одна строка `logGuardBlock(...)` в block-ветке `main()`.
- **Modify (доска):** `tools/status-md-generator.mjs` CLI (:708) — `recentEscapes`/`recentBlocks` из читателей вместо `[]`.
- **Modify (тесты хуков):** к каждому из 9 существующих `enforce-*.test.mjs` — структурный `it()` (readFileSync src + assert `logGuardBlock(`), tdd-gate-совместимо.

---

## Task 1: модуль `guard-block-log.mjs` (TDD, memFs)

**Files:** Create `tools/guard-block-log.mjs` + `tools/guard-block-log.test.mjs`

- [ ] **Step 1: RED-тест (Write `tools/guard-block-log.test.mjs`)**

```javascript
// tools/guard-block-log.test.mjs
import { describe, it, expect } from 'vitest';
import { buildGuardBlockEntry, logGuardBlock, loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';

function memFs(seed = {}) {
  const s = new Map(Object.entries(seed));
  return { s,
    existsSync: (p) => s.has(String(p)),
    readdirSync: (d) => [...s.keys()].filter((k) => k.startsWith(String(d))).map((k) => String(k).slice(String(d).length + 1)),
    readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
    appendFileSync: (p, d) => s.set(String(p), (s.get(String(p)) || '') + d),
    mkdirSync: () => {} };
}
const DIR = '/rt';

describe('buildGuardBlockEntry (pure)', () => {
  it('собирает {ts,machine,action,reason}', () => {
    const e = buildGuardBlockEntry({ machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое', now: 1000 });
    expect(e).toEqual({ ts: 1000, machine: 'М5 Пол', action: 'bash:git push --force', reason: 'необратимое' });
  });
});

describe('logGuardBlock (fail-quiet append)', () => {
  it('пишет строку в guard-blocks-<sess>.jsonl, action из canonicalAction', () => {
    const fs = memFs();
    logGuardBlock({ tool_name: 'Bash', tool_input: { command: 'git   push --force' }, session_id: 's1' },
      'М5 Пол', 'необратимое', { fsImpl: fs, runtimeDir: DIR, now: 5 });
    const raw = fs.s.get('/rt/guard-blocks-s1.jsonl');
    const rec = JSON.parse(raw.trim());
    expect(rec.machine).toBe('М5 Пол');
    expect(rec.reason).toBe('необратимое');
    expect(rec.action).toBe('bash:git push --force'); // нормализовано
    expect(rec.ts).toBe(5);
  });
  it('никогда не бросает (битый fs / битый event)', () => {
    const throwFs = { appendFileSync: () => { throw new Error('disk'); }, mkdirSync: () => {} };
    expect(() => logGuardBlock(null, 'X', 'y', { fsImpl: throwFs, runtimeDir: DIR, now: 1 })).not.toThrow();
  });
});

describe('loadRecentBlocks (скан session-файлов, окно+сорт+cap)', () => {
  it('собирает из всех guard-blocks-*.jsonl, окно, сорт desc, cap, ts→ISO', () => {
    const fs = memFs({
      '/rt/guard-blocks-a.jsonl': JSON.stringify({ ts: 100, machine: 'М5 Пол', action: 'bash:rm', reason: 'r1' }) + '\n',
      '/rt/guard-blocks-b.jsonl': JSON.stringify({ ts: 300, machine: 'М2 Стена', action: 'write:x', reason: 'r2' }) + '\n'
        + JSON.stringify({ ts: 50, machine: 'М2 Стена', action: 'write:y', reason: 'old' }) + '\n',
      '/rt/other.jsonl': 'ignored\n',
    });
    const r = loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 350, windowMs: 1000, limit: 10 });
    expect(r.map((x) => x.action)).toEqual(['write:x', 'bash:rm', 'write:y']); // desc by ts
    expect(r[0].ts).toBe(new Date(300).toISOString());
  });
  it('окно отсекает старое; cap ограничивает; нет файлов → []', () => {
    const fs = memFs({ '/rt/guard-blocks-a.jsonl': JSON.stringify({ ts: 10, machine: 'M', action: 'a', reason: 'r' }) + '\n' });
    expect(loadRecentBlocks({ fsImpl: fs, runtimeDir: DIR, now: 100000, windowMs: 1000, limit: 10 })).toEqual([]);
    expect(loadRecentBlocks({ fsImpl: memFs(), runtimeDir: DIR, now: 1, windowMs: 1000, limit: 10 })).toEqual([]);
  });
});

describe('loadRecentEscapes (askuser-decisions floor_escape)', () => {
  it('собирает floor_escape из всех askuser-decisions-*.jsonl, reason=label', () => {
    const fs = memFs({
      '/rt/askuser-decisions-s1.jsonl':
        JSON.stringify({ type: 'floor_escape', action: 'bash:git push', ts: 200 }) + '\n'
        + JSON.stringify({ type: 'approve_git_operation', action: 'x', ts: 210 }) + '\n', // не floor_escape
    });
    const r = loadRecentEscapes({ fsImpl: fs, runtimeDir: DIR, now: 250, windowMs: 1000, limit: 10 });
    expect(r).toHaveLength(1);
    expect(r[0].action).toBe('bash:git push');
    expect(r[0].reason).toBe('escape владельца');
    expect(r[0].ts).toBe(new Date(200).toISOString());
  });
});
```

- [ ] **Step 2: RED** — из `app/`: `node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs guard-block-log --reporter dot` → FAIL (модуля нет).

- [ ] **Step 3: реализация (Write `tools/guard-block-log.mjs`)**

```javascript
#!/usr/bin/env node
/**
 * guard-block-log — журнал блоков обороны М1–М6 для доски «кто на посту» (D-3).
 * Логгер пишут машинные хуки при РЕШЁННОМ блоке (best-effort, fail-quiet, Node fs —
 * как logVerdictLine/logViolation). Читатели сканируют все session-файлы runtime для
 * глобальной доски (board-генератор не имеет одного session_id). Достоверность журнала —
 * при зарегистрированном поле-страже runtime (Фаза 8); до флипа данных нет (0/0).
 */
import fsDefault from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { canonicalAction } from './escape-grant.mjs';

function defaultRuntimeDir() { return join(homedir(), '.claude', 'runtime'); }

/** Чистая запись блока. */
export function buildGuardBlockEntry({ machine, action, reason, now }) {
  return { ts: now, machine: String(machine ?? ''), action: String(action ?? ''), reason: String(reason ?? '') };
}

/** Best-effort: записать блок машины. action — из canonicalAction(event); sess — из event.session_id.
 *  НИКОГДА не бросает (вызывается в block-ветке хука; сбой логгирования не влияет на блок). */
export function logGuardBlock(event, machine, reason, { fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now() } = {}) {
  try {
    const action = canonicalAction(event && event.tool_name, (event && event.tool_input) || {});
    const sess = (event && event.session_id) || 'unknown';
    const entry = buildGuardBlockEntry({ machine, action, reason, now });
    fsImpl.mkdirSync(runtimeDir, { recursive: true });
    fsImpl.appendFileSync(join(runtimeDir, `guard-blocks-${sess}.jsonl`), JSON.stringify(entry) + '\n');
  } catch { /* fail-quiet */ }
}

function scanSessionFiles(fsImpl, runtimeDir, prefix) {
  let names = [];
  try { names = fsImpl.readdirSync(runtimeDir).filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl')); }
  catch { return []; }
  const out = [];
  for (const name of names) {
    let raw; try { raw = fsImpl.readFileSync(join(runtimeDir, name), 'utf8'); } catch { continue; }
    for (const line of String(raw).split(/\r?\n/)) {
      const t = line.trim(); if (!t) continue;
      let r; try { r = JSON.parse(t); } catch { continue; }
      out.push(r);
    }
  }
  return out;
}

function windowSortCap(recs, { now, windowMs, limit }) {
  return recs
    .filter((r) => r && typeof r.ts === 'number' && now - r.ts >= 0 && now - r.ts <= windowMs)
    .sort((a, b) => b.ts - a.ts)
    .slice(0, limit)
    .map((r) => ({ ...r, ts: new Date(r.ts).toISOString() }));
}

/** Недавние блоки машин для доски. */
export function loadRecentBlocks({ fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now(), windowMs = 86400000, limit = 10 } = {}) {
  const recs = scanSessionFiles(fsImpl, runtimeDir, 'guard-blocks-')
    .map((r) => ({ ts: r.ts, machine: r.machine, action: r.action, reason: r.reason }));
  return windowSortCap(recs, { now, windowMs, limit });
}

/** Недавние escape владельца (floor_escape) для доски. */
export function loadRecentEscapes({ fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now(), windowMs = 86400000, limit = 10 } = {}) {
  const recs = scanSessionFiles(fsImpl, runtimeDir, 'askuser-decisions-')
    .filter((r) => r && r.type === 'floor_escape' && typeof r.action === 'string')
    .map((r) => ({ ts: typeof r.ts === 'number' ? r.ts : 0, machine: 'escape', action: r.action, reason: 'escape владельца' }));
  return windowSortCap(recs, { now, windowMs, limit });
}
```

- [ ] **Step 4: GREEN** — повторить Step 2 → PASS.
- [ ] **Step 5: Commit** (владелец, msg в `.scratch/`).

---

## Task 2: проводка `logGuardBlock` в 9 машинных хуков (структурный TDD per-hook)

**Паттерн на каждый хук:** (a) Read существующий `tools/<hook>.test.mjs`; (b) добавить `it()` со структурной проверкой (RED); (c) добавить `import { logGuardBlock } from './guard-block-log.mjs';` + строку `logGuardBlock(<eventVar>, '<label>', <reasonExpr>)` в block-ветке `main()` (GREEN). Структурный тест — легальный приём для CLI-проводки (содержит it/expect + readFileSync на правимый prod, tdd-real-test-verifier-совместимо; имя файла содержит basename — tdd-gate-совместимо).

**Структурный it() (шаблон, подставить `<hook>`):**
```javascript
import { readFileSync } from 'node:fs';
it('логирует guard-block в block-ветке (D-3 доска)', () => {
  const src = readFileSync(new URL('./<hook>.mjs', import.meta.url), 'utf8');
  expect(src).toMatch(/logGuardBlock\(/);
  expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
});
```

**Таблица проводки (event-переменная / reason / label / якорь):**

| # | Хук | eventVar | reason | label | Вставка |
|---|---|---|---|---|---|
| 1 | `enforce-floor.mjs` | `event` | `r.reason` | `'М5 Пол'` | перед `exitDecision({ block: r.block, message: r.block ? \`[floor]...`: `if (r.block) logGuardBlock(event, 'М5 Пол', r.reason);` |
| 2 | `enforce-supreme-gate.mjs` | `event` | `r.message` | `'М2 Стена'` | перед `exitDecision({ block: r.block, message: r.block ? \`[supreme-gate]...`: `if (r.block) logGuardBlock(event, 'М2 Стена', r.message);` |
| 3 | `enforce-judge-gate.mjs` | `event` | `result.message` | `'М4 Судья'` | в `if (result.block)` ДО `exitDecision`: `logGuardBlock(event, 'М4 Судья', result.message);` |
| 4 | `enforce-snapshot.mjs` | `ev` | `r.message` | `'М6 Снимок'` | перед `exitDecision({ block: r.block, message: r.block ? r.message...`: `if (r.block) logGuardBlock(ev, 'М6 Снимок', r.message);` |
| 5 | `enforce-read-path-deny.mjs` | `event` | `r.reason` | `'М5 Read-страж'` | в `if (r.block) {` ДО `return exitDecision`: `logGuardBlock(event, 'М5 Read-страж', r.reason);` |
| 6 | `enforce-mcp-classification.mjs` | `event` | `r.reason` | `'М5 Egress-страж'` | в `if (r.block) {` ДО `return exitDecision`: `logGuardBlock(event, 'М5 Egress-страж', r.reason);` |
| 7 | `enforce-normative-content-rules.mjs` | `event` | `result.reason` | `'М1/М5 Нормативный'` | рядом с `if (result.block) logViolation(...)`: добавить `if (result.block) logGuardBlock(event, 'М1/М5 Нормативный', result.reason);` |
| 8 | `enforce-verify-gate.mjs` | `event` | `r.message` | `'G1 Verify-gate'` | перед `exitDecision({ block: r.block, message: r.block ? r.message...`: `if (r.block) logGuardBlock(event, 'G1 Verify-gate', r.message);` |
| 9 | `enforce-criterion-gate.mjs` | `event` | `r.message` | `'Level B Criterion'` | перед `exitDecision({ block: r.block, message: r.block ? r.message...`: `if (r.block) logGuardBlock(event, 'Level B Criterion', r.message);` |

> **NB:** логируем ТОЛЬКО решённый блок (`r.block`/`result.block`), НЕ catch-ветки infra-fail-CLOSE (там `event` может быть не распарсен; infra-ошибки реже и сами по себе не «политический» блок). Перед каждой правкой — Read хука для точного якоря (audit-context per-task). enforce-supreme-gate/snapshot — подтвердить eventVar в main() при чтении.

- [ ] **Steps (×9, для каждого хука по таблице):** Read test → добавить структурный `it()` → RED (`node ... <hook> --reporter dot`) → import+строка в хук → GREEN.
- [ ] **Финал Task 2:** прогон `node ... enforce- --reporter dot` (или полный) — все хук-тесты GREEN.
- [ ] **Commit** (владелец).

---

## Task 3: проводка читателей в доску (CLI)

**Files:** Modify `tools/status-md-generator.mjs` (:708) + структурный it() в `tools/status-md-generator.test.mjs`

- [ ] **Step 1: RED — структурный it() (Read `status-md-generator.test.mjs` → добавить):**
```javascript
import { readFileSync } from 'node:fs';
it('CLI передаёт loadRecentEscapes/loadRecentBlocks в доску (D-3 live)', () => {
  const src = readFileSync(new URL('./status-md-generator.mjs', import.meta.url), 'utf8');
  expect(src).toMatch(/loadRecentEscapes\(/);
  expect(src).toMatch(/loadRecentBlocks\(/);
  expect(src).not.toMatch(/recentEscapes: \[\], recentBlocks: \[\]/);
});
```

- [ ] **Step 2: RED** — `node ... status-md-generator --reporter dot` → FAIL.

- [ ] **Step 3: реализация** — в `status-md-generator.mjs`:
  - import: `import { loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';`
  - в CLI (:708) заменить `recentEscapes: [], recentBlocks: []` на:
    ```javascript
    recentEscapes: loadRecentEscapes(), recentBlocks: loadRecentBlocks(),
    ```
    (defaults: runtimeDir = ~/.claude/runtime, окно 24ч, cap 10). Обернуть в существующий try/catch блока (guardBoardBlock) — при сбое читателя доска не падает (catch уже есть).

- [ ] **Step 4: GREEN** — повторить Step 2 → PASS.
- [ ] **Step 5: Commit** (владелец).

---

## Гейт закрытия (после Task 3)
- [ ] `sharp-edges` по коду (логгер fail-quiet корректен; читатели не бросают на битый JSON; нет инъекции через action/reason — `escapeCell` в доске уже экранирует).
- [ ] `variant-analysis` (нет ли block-веток машин, пропущенных проводкой; нет ли иного источника блоков, который доска должна учесть).
- [ ] regression tools-only `node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs --reporter dot` ≥ **3449+2skip + новые** (Task 1 ~7 + Task 2 ~9 + Task 3 ~1 ≈ 3466), 0 регрессий.
- [ ] `verification-before-completion`.

---

## Self-Review
**Spec coverage:** D-3 «recentEscapes/recentBlocks live-источник» → Task 1 (источник) + Task 2 (наполнение блоков) + Task 3 (проводка escapes+blocks в доску). ✅
**Placeholder scan:** код модуля/тестов/проводки приведён; per-hook таблица даёт точные вставки. Якоря normative подтверждён (:266-267). ✅
**Type consistency:** `logGuardBlock(event, machine, reason, opts)` — единая сигнатура во всех 9 хуках; `loadRecentBlocks`/`loadRecentEscapes` возвращают `{ts(ISO),machine,action,reason}` — форма, которую рендерит `computeGuardBoardBlock.detailTable` (`e.ts/e.action/e.reason`). ✅
**Не-цели (YAGNI):** не логируем catch-fail-CLOSE; не трогаем pure `decide()` (логгер только в `main()`); не трогаем `computeGuardBoardBlock` (presentation готов); не меняем escape-grant/judge-verdicts.
