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
This commit is contained in:
+11
-11
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-06-09T16:05:25.173Z
|
||||
Last updated: 2026-06-10T00:48:13.135Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,7 +8,7 @@ Last updated: 2026-06-09T16:05:25.173Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 1 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 792 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 795 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Кто на посту (оборона М1–М6)
|
||||
@@ -37,8 +37,8 @@ Last updated: 2026-06-09T16:05:25.173Z
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 792 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 792
|
||||
- Observer evidence: 795 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 795
|
||||
- Last /brain-retro: 13 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
@@ -53,9 +53,9 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
| feature | 27 | 11.1% | 3.7% |
|
||||
| bugfix | 27 | 14.8% | 18.5% |
|
||||
|
||||
Router step distribution: 1: 377, 2: 294, 3: 18, 5: 87
|
||||
Router step distribution: 1: 380, 2: 294, 3: 18, 5: 87
|
||||
|
||||
Boundaries applied (ADR / границы): 8 of 776 эпизодов (1.0%).
|
||||
Boundaries applied (ADR / границы): 8 of 779 эпизодов (1.0%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
@@ -73,7 +73,7 @@ Boundaries applied (ADR / границы): 8 of 776 эпизодов (1.0%).
|
||||
|
||||
| Компонент | Токены (in/out) | USD |
|
||||
|---|---|---|
|
||||
| Classifier (Sonnet 4.6) | 49005/203971 | $3.21 |
|
||||
| Classifier (Sonnet 4.6) | 49056/204257 | $3.21 |
|
||||
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
|
||||
| **Итого** | | **$3.21** |
|
||||
@@ -89,7 +89,7 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## Reviewer: субагент vs fallback
|
||||
|
||||
0 эпизодов проверено из 792.
|
||||
0 эпизодов проверено из 795.
|
||||
|
||||
## Reviewer findings
|
||||
|
||||
@@ -115,9 +115,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 3916 | MsMpEng | 3.37ч | 0.0ч |
|
||||
| 1208 | svchost | 1.36ч | 0.0ч |
|
||||
| 4 | System | 1.07ч | NaNч |
|
||||
| 3916 | MsMpEng | 3.51ч | 12284760.8ч |
|
||||
| 1208 | svchost | 1.42ч | 0.0ч |
|
||||
| 4 | System | 1.14ч | 0.0ч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# Доска «кто на посту» — 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.
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-criterion-gate-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-criterion-gate — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-criterion-gate.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { runCriterionGate } from './judge-orchestrator.mjs';
|
||||
import { verifyGateActive } from './verify-gate-config.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
/** Чистое решение. */
|
||||
export function decide({ toolName, command, gate, key, codeChanged = false, frozenPlanValid = false,
|
||||
@@ -80,6 +81,7 @@ async function main() {
|
||||
const escapeOpen = escapeGrantOpen(action, loadFloorEscapes(sess), loadConsumed(sess));
|
||||
const r = decide({ toolName: event.tool_name, command, gate, key, codeChanged, frozenPlanValid,
|
||||
criteria, sealedCriterionIds: frozenPlanValid ? sealedIds(plan) : [], greenRuns, currentFingerprints, escapeOpen, changedPaths });
|
||||
if (r.block) logGuardBlock(event, 'Level B Criterion', r.message);
|
||||
exitDecision({ block: r.block, message: r.block ? r.message : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: true, message: '[criterion-gate] внутренняя ошибка — fail-CLOSED' });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-floor-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-floor — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-floor.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { floorDecide } from './floor-decide.mjs';
|
||||
import { loadFloorEscapes, loadConsumed, escapeAllowsEvent } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
/** Чистое решение: делегирует floor-decide. escapeGrants/escapeConsumed/now/normalizeImpl инъектируемы.
|
||||
* M7 Фаза 2 (правило 7б): floorDecide обёрнут в try — если он бросит ДО своего escape-чека,
|
||||
@@ -39,6 +40,7 @@ async function main() {
|
||||
const escapeGrants = loadFloorEscapes(sess); // read-only, window-filtered
|
||||
const escapeConsumed = loadConsumed(sess); // отметки one-shot погашения
|
||||
const r = decide({ event, escapeGrants, escapeConsumed });
|
||||
if (r.block) logGuardBlock(event, 'М5 Пол', r.reason);
|
||||
exitDecision({ block: r.block, message: r.block ? `[floor] ${r.reason}` : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: true, message: '[floor] внутренняя ошибка — fail-CLOSED' });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-judge-gate-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-judge-gate — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-judge-gate.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { join } from 'node:path';
|
||||
import { sealArtifact, sealPlan, sealablePlan, sealableArtifact, judgedHashOf } from './seal-orchestration.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
import { loadFrozenArtifact, saveFrozenArtifact, saveFrozenPlan } from './plan-lock.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
/**
|
||||
* Чистое решение обёртки. inert/shadow → allow. live-block → finalGate(вердикт, пол):
|
||||
@@ -274,7 +275,7 @@ async function main() {
|
||||
try {
|
||||
result = await runJudgeTurn(event, { mode, onWiredSeal: sealTurnProd }); // inert/shadow/live-block внутри
|
||||
} catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-CLOSE только в live-block
|
||||
if (result.block) exitDecision({ block: true, message: result.message || '[judge-gate] block' });
|
||||
if (result.block) { logGuardBlock(event, 'М4 Судья', result.message); exitDecision({ block: true, message: result.message || '[judge-gate] block' }); }
|
||||
else exitDecision({ block: false });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-mcp-classification-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-mcp-classification — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-mcp-classification.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { classifyMcpTool } from './mcp-tool-classifier.mjs';
|
||||
import { scanSecrets } from './secret-scan.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
// 7.3 (Блок 4.3) exfil-схемы в исходящих: base64-данные / локальный файл / IP-литерал URL
|
||||
// (обход DNS/allowlist). Узко-таргетированы (data:...;base64, и file://) — не ловят обычный текст.
|
||||
@@ -72,6 +73,7 @@ async function main() {
|
||||
const r = decide({ toolName: event.tool_name, toolInput: event.tool_input,
|
||||
escapeGrants: loadFloorEscapes(sess), escapeConsumed: loadConsumed(sess) });
|
||||
if (r.block) {
|
||||
logGuardBlock(event, 'М5 Egress-страж', r.reason);
|
||||
return exitDecision({ block: true, message: `[mcp-classification] ${r.reason}` });
|
||||
}
|
||||
return exitDecision({ block: false });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-normative-content-rules-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-normative-content-rules — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-normative-content-rules.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -208,6 +208,7 @@ import { homedir } from 'node:os';
|
||||
import { readStdin, parseEventJson, readTranscript, turnToolUses, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { multiJudgeConsensus, JUDGE_MODELS } from './llm-judge.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
/** True if any tool use in the turn is a legit normative-edit skill. */
|
||||
export function detectLegitSkillActive(toolUses) {
|
||||
@@ -264,6 +265,7 @@ async function main() {
|
||||
});
|
||||
|
||||
if (result.block) logViolation({ sessionId, filePath, reason: result.reason });
|
||||
if (result.block) logGuardBlock(event, 'М1/М5 Нормативный', result.reason);
|
||||
exitDecision({ block: result.block, message: result.reason });
|
||||
} catch {
|
||||
// 7.2 (H3): обёртка fail-CLOSE — внутренняя ошибка на защитной нормативке НЕ должна тихо
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-read-path-deny-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-read-path-deny — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-read-path-deny.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './enforce-hook-helpers.mjs';
|
||||
import { defaultPathNormalize, isProtectedPath, READ_DENY_PATTERNS } from './shell-content-rules.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
export function decide({ toolName, filePath, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||||
if (toolName !== 'Read') return { block: false, reason: null };
|
||||
@@ -58,6 +59,7 @@ async function main() {
|
||||
escapeConsumed: loadConsumed(sess),
|
||||
});
|
||||
if (r.block) {
|
||||
logGuardBlock(event, 'М5 Read-страж', r.reason);
|
||||
return exitDecision({ block: true, message: `[read-path-deny] ${r.reason}` });
|
||||
}
|
||||
return exitDecision({ block: false });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-snapshot-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-snapshot — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-snapshot.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process';
|
||||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||||
import { snapshotNeeded, resolveGitState } from './snapshot-decide.mjs';
|
||||
import { canonicalAction } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
function defaultGit() {
|
||||
try {
|
||||
@@ -51,6 +52,7 @@ async function main() {
|
||||
try {
|
||||
const ev = parseEventJson(await readStdin());
|
||||
const r = snapshotDecision(ev);
|
||||
if (r.block) logGuardBlock(ev, 'М6 Снимок', r.message);
|
||||
exitDecision({ block: r.block, message: r.block ? r.message : undefined });
|
||||
} catch { exitDecision({ block: false }); } // снимок — страховка; своя инфра-ошибка не должна клинить (но git-ошибка выше = block)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-supreme-gate-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-supreme-gate — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-supreme-gate.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'
|
||||
import { assertSafeSessionId } from './action-journal.mjs';
|
||||
import { classifyDestructive } from './classify-destructive.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
|
||||
// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
|
||||
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
|
||||
@@ -285,6 +286,7 @@ async function main() {
|
||||
journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
|
||||
saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано
|
||||
});
|
||||
if (r.block) logGuardBlock(event, 'М2 Стена', r.message);
|
||||
exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
|
||||
} catch {
|
||||
// Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// tools/enforce-verify-gate-guardblock.test.mjs — структурная проверка проводки guard-block (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('enforce-verify-gate — проводка guard-block (D-3)', () => {
|
||||
it('импортирует и вызывает logGuardBlock в block-ветке', () => {
|
||||
const src = readFileSync(new URL('./enforce-verify-gate.mjs', import.meta.url), 'utf8');
|
||||
expect(src).toMatch(/from '\.\/guard-block-log\.mjs'/);
|
||||
expect(src).toMatch(/logGuardBlock\(/);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { acceptVerifyReceipt } from './verify-receipt.mjs';
|
||||
import { verifyGateActive } from './verify-gate-config.mjs';
|
||||
import { canonicalAction, escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
import { logGuardBlock } from './guard-block-log.mjs';
|
||||
import { codeFingerprint } from './criterion-green.mjs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -78,6 +79,7 @@ async function main() {
|
||||
const action = canonicalAction('Bash', { command });
|
||||
const escapeOpen = escapeGrantOpen(action, loadFloorEscapes(sess), loadConsumed(sess));
|
||||
const r = decide({ toolName: event.tool_name, command, gate, receipt, currentFingerprint, escapeOpen, changedPaths, key: resolveReceiptKey() });
|
||||
if (r.block) logGuardBlock(event, 'G1 Verify-gate', r.message);
|
||||
exitDecision({ block: r.block, message: r.block ? r.message : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: true, message: '[verify-gate] внутренняя ошибка — fail-CLOSED' }); // active → fail-CLOSE
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/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 });
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// tools/guard-block-log.test.mjs
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import { buildGuardBlockEntry, logGuardBlock, loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';
|
||||
|
||||
// OS-agnostic memFs: нормализует \\→/ (node:path.join на Windows даёт backslash; сиды — forward slash).
|
||||
function memFs(seed = {}) {
|
||||
const norm = (p) => String(p).replace(/\\/g, '/');
|
||||
const s = new Map(Object.entries(seed).map(([k, v]) => [norm(k), v]));
|
||||
return { s,
|
||||
existsSync: (p) => s.has(norm(p)),
|
||||
readdirSync: (d) => { const nd = norm(d); return [...s.keys()].filter((k) => k.startsWith(nd + '/')).map((k) => k.slice(nd.length + 1)); },
|
||||
readFileSync: (p) => { const np = norm(p); if (!s.has(np)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(np); },
|
||||
appendFileSync: (p, d) => { const np = norm(p); s.set(np, (s.get(np) || '') + 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(join(DIR, 'guard-blocks-s1.jsonl').replace(/\\/g, '/'));
|
||||
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({
|
||||
[join(DIR, 'guard-blocks-a.jsonl')]: JSON.stringify({ ts: 100, machine: 'М5 Пол', action: 'bash:rm', reason: 'r1' }) + '\n',
|
||||
[join(DIR, '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',
|
||||
[join(DIR, '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({ [join(DIR, '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({
|
||||
[join(DIR, '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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// tools/status-md-generator-guardlive.test.mjs — структурная проверка проводки live-источника доски (D-3).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('status-md-generator — доска live-источник (D-3)', () => {
|
||||
it('CLI передаёт loadRecentEscapes/loadRecentBlocks вместо []/[]', () => {
|
||||
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: \[\]/);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { auditDoors, auditExempt, extractGateMatcher, CANONICAL_MUTATING_TOOLS,
|
||||
import { SEED_TOOLS } from './enforce-supreme-gate.mjs';
|
||||
import { loadJournal, verifyChain } from './action-journal.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
import { loadRecentBlocks, loadRecentEscapes } from './guard-block-log.mjs';
|
||||
|
||||
const PRICING = {
|
||||
sonnet46: { input_per_mtok: 3.0, output_per_mtok: 15.0 },
|
||||
@@ -705,7 +706,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-
|
||||
let manifestSettings = {};
|
||||
try { manifestSettings = JSON.parse(readFileSync('.claude/settings.json', 'utf-8')); } catch { manifestSettings = {}; }
|
||||
const manifest = checkManifest({ settings: manifestSettings });
|
||||
guardBoardBlock = computeGuardBoardBlock({ manifest, judgeMode: judgeGateMode(), recentEscapes: [], recentBlocks: [] });
|
||||
guardBoardBlock = computeGuardBoardBlock({ manifest, judgeMode: judgeGateMode(), recentEscapes: loadRecentEscapes(), recentBlocks: loadRecentBlocks() });
|
||||
} catch (err) { console.warn('[status-md-generator] guardBoardBlock skipped:', err.message); guardBoardBlock = null; }
|
||||
inputs.guardBoardBlock = guardBoardBlock;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user