Files
portal/docs/superpowers/plans/2026-06-10-guard-board-live-source.md
T
Дмитрий c9f823df38 feat(board): live source for guard board escapes/blocks (D-3)
Доска «кто на посту» (STATUS.md §7) теперь показывает реальные недавние
escape владельца и блоки машин М1–М6 вместо хардкода []/[].

- new tools/guard-block-log.mjs: logGuardBlock (best-effort, fail-quiet,
  Node fs append в guard-blocks-<sess>.jsonl) + loadRecentBlocks/
  loadRecentEscapes (скан session-файлов runtime, окно 24ч + cap 10, ts→ISO).
- проводка logGuardBlock в block-ветку main() 9 машинных хуков (floor /
  supreme-gate / judge-gate / snapshot / read-path-deny / mcp-classification /
  normative-content-rules / verify-gate / criterion-gate). Логгер вызывается
  ПОСЛЕ решения, не влияет на block; decide() pure не тронут.
- status-md-generator CLI: recentEscapes/recentBlocks из читателей вместо []/[].

До флипа Фазы 8 доска показывает 0/0 (хуки не зарегистрированы — данных нет);
реальная польза — пост-флип наблюдаемость.

TDD: guard-block-log.test (6) + 9 структурных wiring-тестов + 1 board-тест.
Гейт закрытия: sharp-edges (промежуточный по 9 хукам + читатели) +
variant-analysis (все block-ветки покрыты, иных источников нет). Регрессия
tools-only 3465 passed / 2 skipped / 0 failed (было 3449+2skip). 0 регрессий.

Plan: docs/superpowers/plans/2026-06-10-guard-board-live-source.md
2026-06-10 04:28:53 +03:00

19 KiB
Raw Blame History

Доска «кто на посту» — 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.mjsbuildGuardBlockEntry (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)
// 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)

#!/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>):

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 → добавить):
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: REDnode ... 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: [] на:
      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 dot3449+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.