diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 22aba985..7e7a52c2 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий. diff --git a/docs/superpowers/plans/2026-06-10-guard-board-live-source.md b/docs/superpowers/plans/2026-06-10-guard-board-live-source.md new file mode 100644 index 00000000..eb264431 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-guard-board-live-source.md @@ -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-.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-.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/.test.mjs`; (b) добавить `it()` со структурной проверкой (RED); (c) добавить `import { logGuardBlock } from './guard-block-log.mjs';` + строку `logGuardBlock(, '