Доска «кто на посту» (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
19 KiB
Доска «кто на посту» — 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.mjsCLI (:708) —recentEscapes/recentBlocksиз читателей вместо[]. - Modify (тесты хуков): к каждому из 9 существующих
enforce-*.test.mjs— структурныйit()(readFileSync src + assertlogGuardBlock(), 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: 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: []на:(defaults: runtimeDir = ~/.claude/runtime, окно 24ч, cap 10). Обернуть в существующий try/catch блока (guardBoardBlock) — при сбое читателя доска не падает (catch уже есть).recentEscapes: loadRecentEscapes(), recentBlocks: loadRecentBlocks(),
- import:
-
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.