dcc14f83ec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
725 lines
34 KiB
JavaScript
725 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { execFileSync } from 'child_process';
|
||
import { homedir } from 'os';
|
||
import { runCoverageChecker } from './observer-coverage-checker.mjs';
|
||
import { analyze } from './brain-retro-analyzer.mjs';
|
||
import { loadRegistry } from './registry-load.mjs';
|
||
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
|
||
import { computeOverrideUsageBlock } from './enforce-override-monitor.mjs';
|
||
import { queryRunningProcesses, computeSystemHealthBlock } from './system-health.mjs';
|
||
import { checkManifest } from './floor-manifest-check.mjs';
|
||
import { judgeGateMode } from './judge-gate-config.mjs';
|
||
import { loadQueue, statusSignal, pendingCount } from './router-learning-queue.mjs';
|
||
import { auditDoors, auditExempt, extractGateMatcher, CANONICAL_MUTATING_TOOLS, isMutatingTool } from './door-coverage.mjs';
|
||
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 },
|
||
opus47: { input_per_mtok: 15.0, output_per_mtok: 75.0 },
|
||
};
|
||
|
||
function iconFor(status) {
|
||
return { ok: '✅', warn: '⚠️', fail: '🔴' }[status] || '⚪';
|
||
}
|
||
|
||
const GUARD_LABELS = {
|
||
'enforce-floor.mjs': 'М5 Пол (вето-до-плана / content-floor)',
|
||
'enforce-supreme-gate.mjs': 'М2 Стена (действие = шаг плана)',
|
||
'enforce-judge-gate.mjs': 'М4 Судья (приёмка + надзор)',
|
||
'enforce-snapshot.mjs': 'М6 Снимок (точка отката)',
|
||
'enforce-floor-escape-consume.mjs': 'М6 Escape владельца (законная дверь)',
|
||
'enforce-read-path-deny.mjs': 'М5 Read-exfil страж',
|
||
'enforce-mcp-classification.mjs': 'М5 Egress-exfil страж',
|
||
'enforce-normative-content-rules.mjs': 'М1/М5 Нормативный страж (КАРТА/ЗАКОН)',
|
||
'enforce-skill-journaler.mjs': 'М1 Журналер навыков',
|
||
};
|
||
|
||
/**
|
||
* §7 доска «кто на посту» — read-only снимок обороны М1–М6 (Кусок 3 М7). Манифест (checkManifest)
|
||
* даёт registered/missing; missing → «⚠️ ПОСТ ПУСТОЙ» (нельзя объявить «оборона стоит» без
|
||
* подтверждения регистрации, SE-B/Δ8). + режим судьи М4 + счётчики недавних escape/блоков. Чистая,
|
||
* ничего не блокирует.
|
||
*/
|
||
/** Экранировать значение для одной ячейки markdown-таблицы (anti-injection, sharp-edges SE-3). */
|
||
function escapeCell(v) {
|
||
return String(v ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').slice(0, 120);
|
||
}
|
||
|
||
export function computeGuardBoardBlock({ manifest, judgeMode = 'inert', recentEscapes = [], recentBlocks = [] } = {}) {
|
||
const m = manifest || { registered: [], missing: [], ok: false };
|
||
const reg = Array.isArray(m.registered) ? m.registered : [];
|
||
const miss = Array.isArray(m.missing) ? m.missing : [];
|
||
const post = m.ok && miss.length === 0
|
||
? '✅ ПОСТ ПОЛНЫЙ — все стражи М1–М6 зарегистрированы'
|
||
: `⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: ${miss.join(', ') || '(неизвестно)'} (оборона НЕ подтверждена; SE-B/Δ8)`;
|
||
const rows = [...reg, ...miss].map((h) => {
|
||
const isReg = reg.includes(h);
|
||
return `| ${GUARD_LABELS[h] || h} | \`${h}\` | ${isReg ? '✅' : '🔴'} |`;
|
||
}).join('\n');
|
||
const esc = Array.isArray(recentEscapes) ? recentEscapes.length : 0;
|
||
const blk = Array.isArray(recentBlocks) ? recentBlocks.length : 0;
|
||
const detailTable = (events, title) => {
|
||
if (!Array.isArray(events) || events.length === 0) return '';
|
||
const trows = events
|
||
.map((e) => `| ${escapeCell(e && e.ts)} | ${escapeCell(e && e.action)} | ${escapeCell(e && e.reason)} |`)
|
||
.join('\n');
|
||
return `\n**${title}:**\n\n| Время | Действие | Причина |\n|---|---|---|\n${trows}\n`;
|
||
};
|
||
const detail =
|
||
detailTable(recentEscapes, 'Недавние escape владельца (детали)') +
|
||
detailTable(recentBlocks, 'Недавние блоки (детали)');
|
||
return `## Кто на посту (оборона М1–М6)
|
||
|
||
${post}
|
||
|
||
Судья М4: **${judgeMode}** (inert $0 / shadow / floor-only / live-block)
|
||
|
||
| Машина / страж | Хук | Зарегистрирован |
|
||
|---|---|---|
|
||
${rows || '| (нет требуемых хуков) | — | — |'}
|
||
|
||
Недавние escape владельца: ${esc} · Недавние блоки: ${blk}
|
||
${detail}`;
|
||
}
|
||
|
||
/**
|
||
* R-09 (Блок B Класс 1) — read-only блок очереди обучения роутера в STATUS.
|
||
* Реюз `statusSignal`/`pendingCount` из router-learning-queue. Наполнение фонда —
|
||
* ТОЛЬКО по явному «да» владельца (hard-rule модуля); здесь только показываем pending.
|
||
*/
|
||
export function computeLearningQueueBlock({ queue } = {}) {
|
||
const q = Array.isArray(queue) ? queue : [];
|
||
if (pendingCount(q) === 0) {
|
||
return `## Очередь обучения роутера\n\nОчередь пуста — нет кандидатов на одобрение.`;
|
||
}
|
||
const lines = q.filter((e) => e && e.status === 'pending')
|
||
.map((e) => `- [${escapeCell(e.id)}] (${escapeCell(e.kind)}) ${escapeCell(e.summary)}`)
|
||
.join('\n');
|
||
return `## Очередь обучения роутера\n\n${statusSignal(q)} — наполнение фонда ТОЛЬКО по явному «да» владельца (см. \`/brain-retro\`).\n\n${lines}`;
|
||
}
|
||
|
||
/**
|
||
* R-24 (Блок B Класс 2) — read-only блок покрытия дверей. Инструмент, не покрытый matcher'ом
|
||
* верховной стены и не семя = «забытая дверь» (урок F1: PowerShell мимо Bash). auditExempt
|
||
* страхует: семя, чья способность мутирующая, = опасное исключение. Гейт не трогается.
|
||
*/
|
||
export function computeDoorCoverageBlock({ settings } = {}) {
|
||
const s = settings || {};
|
||
const matcher = extractGateMatcher(s, 'enforce-supreme-gate.mjs');
|
||
const seeds = [...SEED_TOOLS];
|
||
const doors = auditDoors({ tools: CANONICAL_MUTATING_TOOLS, matcher, seeds });
|
||
const exempt = auditExempt({ exempt: seeds, isMutating: isMutatingTool });
|
||
const matcherStr = matcher.includes('*') ? '*' : (matcher.join(', ') || '(хук НЕ зарегистрирован)');
|
||
if (doors.ok && exempt.ok) {
|
||
return `## Покрытие дверей\n\n✅ Все двери покрыты верховной стеной М2 (matcher: ${escapeCell(matcherStr)}).`;
|
||
}
|
||
const parts = [`## Покрытие дверей\n\n⚠️ Есть забытые двери (matcher: ${escapeCell(matcherStr)}).`];
|
||
if (!doors.ok) {
|
||
parts.push(`\nНепокрытые мутирующие инструменты: ${doors.uncovered.map(escapeCell).join(', ')}`);
|
||
}
|
||
if (!exempt.ok) {
|
||
parts.push(`\n🔴 Опасные исключения (семя с мутирующей способностью): ${exempt.flagged.map(escapeCell).join(', ')}`);
|
||
}
|
||
return parts.join('\n');
|
||
}
|
||
|
||
/**
|
||
* R-30 (Блок B Класс 3) — read-only блок целостности журналов действий. Требование
|
||
* «verifyChain живьём» выполнено observation-only: sweep по ~/.claude/runtime/action-journal-*
|
||
* делает main, сюда приходят уже посчитанные results. Без новых block-путей, fail-CLOSE
|
||
* примитива verifyChain не понижается. keyAvailable=false → не «битые», а «проверка недоступна».
|
||
*/
|
||
export function computeJournalIntegrityBlock({ results, keyAvailable } = {}) {
|
||
if (keyAvailable === false) {
|
||
return `## Целостность журналов действий\n\nКлюч подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3).`;
|
||
}
|
||
const r = Array.isArray(results) ? results : [];
|
||
if (r.length === 0) {
|
||
return `## Целостность журналов действий\n\nЖурналов сессий не найдено (\`~/.claude/runtime/action-journal-*.jsonl\`).`;
|
||
}
|
||
const broken = r.filter((x) => x && !x.ok);
|
||
if (broken.length === 0) {
|
||
return `## Целостность журналов действий\n\n✅ Все цепочки целы (${r.length} сессий, verifyChain live).`;
|
||
}
|
||
const rows = broken.map((x) => `| \`${escapeCell(x.sessionId)}\` | ${x.brokenAt === null || x.brokenAt === undefined ? '—' : escapeCell(x.brokenAt)} |`).join('\n');
|
||
return `## Целостность журналов действий\n\n🔴 Битые цепочки (${broken.length} из ${r.length}):\n\n| session | broken at seq |\n|---|---|\n${rows}`;
|
||
}
|
||
|
||
export function computeCostBlock(episodes, pricing = PRICING) {
|
||
let classifierInputTok = 0, classifierOutputTok = 0;
|
||
let selfInputTok = 0, selfOutputTok = 0;
|
||
let reviewerSubagentUsd = 0, reviewerInputTok = 0, reviewerOutputTok = 0, reviewerFallbackUsd = 0;
|
||
|
||
for (const ep of episodes) {
|
||
const tc = ep.task_cost;
|
||
if (!tc) continue;
|
||
classifierInputTok += tc.classifier_input_tokens || 0;
|
||
classifierOutputTok += tc.classifier_output_tokens || 0;
|
||
selfInputTok += tc.self_assessment_input_tokens || 0;
|
||
selfOutputTok += tc.self_assessment_output_tokens || 0;
|
||
reviewerSubagentUsd += tc.reviewer_subagent_usd || 0;
|
||
reviewerInputTok += tc.reviewer_input_tokens || 0;
|
||
reviewerOutputTok += tc.reviewer_output_tokens || 0;
|
||
reviewerFallbackUsd += tc.reviewer_direct_fallback_usd || 0;
|
||
}
|
||
|
||
const s46 = pricing.sonnet46;
|
||
const o47 = pricing.opus47;
|
||
const classifierUsd = (classifierInputTok / 1e6) * s46.input_per_mtok + (classifierOutputTok / 1e6) * s46.output_per_mtok;
|
||
const selfUsd = (selfInputTok / 1e6) * s46.input_per_mtok + (selfOutputTok / 1e6) * s46.output_per_mtok;
|
||
const reviewerUsd = reviewerSubagentUsd + (reviewerInputTok / 1e6) * o47.input_per_mtok + (reviewerOutputTok / 1e6) * o47.output_per_mtok + reviewerFallbackUsd;
|
||
const totalUsd = classifierUsd + selfUsd + reviewerUsd;
|
||
|
||
return `## Стоимость месяца
|
||
|
||
| Компонент | Токены (in/out) | USD |
|
||
|---|---|---|
|
||
| Classifier (Sonnet 4.6) | ${classifierInputTok}/${classifierOutputTok} | $${classifierUsd.toFixed(2)} |
|
||
| Self-assessment (Sonnet 4.6) | ${selfInputTok}/${selfOutputTok} | $${selfUsd.toFixed(2)} |
|
||
| Reviewer (Opus 4.7 + fallback) | ${reviewerInputTok}/${reviewerOutputTok} | $${reviewerUsd.toFixed(2)} |
|
||
| **Итого** | | **$${totalUsd.toFixed(2)}** |
|
||
`;
|
||
}
|
||
|
||
export function computeAnomalyBlock(episodes) {
|
||
const values = episodes
|
||
.map(ep => ep.task_cost?.classifier_output_tokens || 0)
|
||
.filter(v => v > 0);
|
||
|
||
let anomalyLine = 'Аномалий нет.';
|
||
|
||
if (values.length > 0) {
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const mid = Math.floor(sorted.length / 2);
|
||
const median = sorted.length % 2 === 0
|
||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||
: sorted[mid];
|
||
const threshold = Math.max(median * 3, 5000);
|
||
const outliers = values
|
||
.map((v, i) => ({ v, i }))
|
||
.filter(({ v }) => v > threshold)
|
||
.sort((a, b) => b.v - a.v)
|
||
.slice(0, 5);
|
||
|
||
if (outliers.length > 0) {
|
||
const rows = outliers
|
||
.map(({ v, i }) => `| episode[${i}] | ${v} | медиана ${median.toFixed(0)}, порог ${threshold.toFixed(0)} |`)
|
||
.join('\n');
|
||
anomalyLine = `| Эпизод | classifier_output_tokens | Примечание |\n|---|---|---|\n${rows}`;
|
||
}
|
||
}
|
||
|
||
return `## Аномалии классификатора
|
||
|
||
${anomalyLine}
|
||
`;
|
||
}
|
||
|
||
export function computeSelfRetrospectBlock(counterPath, fsImpl = { existsSync, readFileSync }) {
|
||
if (!fsImpl.existsSync(counterPath)) {
|
||
return `## Авто-ретроспектива
|
||
|
||
Last self-retrospect: never
|
||
`;
|
||
}
|
||
try {
|
||
const data = JSON.parse(fsImpl.readFileSync(counterPath, 'utf-8'));
|
||
const lastRunAt = data.last_run_at || null;
|
||
const episodesSince = data.episodes_since_last ?? 0;
|
||
const threshold = data.threshold ?? 10;
|
||
|
||
const daysAgo = lastRunAt
|
||
? Math.floor((Date.now() - new Date(lastRunAt).getTime()) / 86400000)
|
||
: null;
|
||
const retroLine = daysAgo === null ? 'never' : `${daysAgo} day(s) ago`;
|
||
const warn = episodesSince >= threshold ? ` ⚠️ (${episodesSince} эпизодов с последнего запуска, порог ${threshold})` : '';
|
||
|
||
return `## Авто-ретроспектива
|
||
|
||
Last self-retrospect: ${retroLine}${warn}
|
||
Episodes since last run: ${episodesSince} / threshold: ${threshold}
|
||
`;
|
||
} catch {
|
||
return `## Авто-ретроспектива
|
||
|
||
Last self-retrospect: never
|
||
`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Brain-retro #5 candidate B (2026-05-26): session-length warning.
|
||
*
|
||
* Long sessions correlate with discipline drift — reviewer pass on retro #5
|
||
* showed regulated rate dropped 19% → 4.5% during a long session.
|
||
*
|
||
* Algorithm: group episodes by task_id (session id), compute MAX
|
||
* session_turn per session over the current calendar day (UTC), surface
|
||
* sessions with turn count >= threshold.
|
||
*
|
||
* Pure — takes episodes array, returns markdown string. No I/O.
|
||
*/
|
||
export function computeSessionLengthBlock(episodes, opts = {}) {
|
||
const threshold = opts.threshold ?? 50;
|
||
const now = opts.now ? new Date(opts.now) : new Date();
|
||
const todayUtc = now.toISOString().slice(0, 10);
|
||
|
||
if (!Array.isArray(episodes) || episodes.length === 0) {
|
||
return `## Длинные сессии\n\n(нет данных)`;
|
||
}
|
||
|
||
const sessions = new Map();
|
||
for (const e of episodes) {
|
||
if (!e || !e.task_id || !e.timestamps?.started_at) continue;
|
||
if (e.timestamps.started_at.slice(0, 10) !== todayUtc) continue;
|
||
const turn = Number(e.environment?.session_turn);
|
||
if (!Number.isFinite(turn)) continue;
|
||
const id = e.task_id;
|
||
const cur = sessions.get(id) || { maxTurn: 0, lastSeen: '', regulated: 0, total: 0 };
|
||
if (turn > cur.maxTurn) cur.maxTurn = turn;
|
||
if (e.timestamps.started_at > cur.lastSeen) cur.lastSeen = e.timestamps.started_at;
|
||
cur.total++;
|
||
if (e.path_type === 'regulated') cur.regulated++;
|
||
sessions.set(id, cur);
|
||
}
|
||
|
||
const longOnes = [...sessions.entries()]
|
||
.filter(([, v]) => v.maxTurn >= threshold)
|
||
.sort((a, b) => b[1].maxTurn - a[1].maxTurn);
|
||
|
||
if (longOnes.length === 0) {
|
||
return `## Длинные сессии\n\nНи одной сессии с >${threshold} ходов сегодня (UTC). ✅`;
|
||
}
|
||
|
||
const rows = longOnes.map(([id, v]) => {
|
||
const regPct = v.total > 0 ? ((v.regulated / v.total) * 100).toFixed(0) : '—';
|
||
const shortId = id.slice(0, 8);
|
||
return `| \`${shortId}\` | ${v.maxTurn} | ${regPct}% | ${v.lastSeen} |`;
|
||
}).join('\n');
|
||
|
||
return `## Длинные сессии
|
||
|
||
⚠️ Сегодня (${todayUtc} UTC) есть сессии с ≥${threshold} ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
|
||
|
||
| session_id | макс. ход | % regulated | последний эпизод |
|
||
|---|---|---|---|
|
||
${rows}
|
||
|
||
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.`;
|
||
}
|
||
|
||
export function computeReviewerBlock(episodes) {
|
||
const reviewed = episodes.filter(ep => ep.review?.reviewed_at !== null && ep.review?.reviewed_at !== undefined);
|
||
const total = episodes.length;
|
||
const reviewedCount = reviewed.length;
|
||
|
||
if (reviewedCount === 0) {
|
||
return `## Reviewer: субагент vs fallback
|
||
|
||
0 эпизодов проверено из ${total}.
|
||
`;
|
||
}
|
||
|
||
const counts = {};
|
||
let errors = 0;
|
||
for (const ep of reviewed) {
|
||
const r = ep.review?.reviewer || 'unknown';
|
||
counts[r] = (counts[r] || 0) + 1;
|
||
if (ep.review?.reviewer_error) errors++;
|
||
}
|
||
|
||
const rows = Object.entries(counts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([name, cnt]) => `| ${name} | ${cnt} | ${((cnt / reviewedCount) * 100).toFixed(1)}% |`)
|
||
.join('\n');
|
||
|
||
return `## Reviewer: субагент vs fallback
|
||
|
||
Проверено: ${reviewedCount} из ${total} эпизодов (${((reviewedCount / total) * 100).toFixed(1)}%). Ошибок ревьюера: ${errors}.
|
||
|
||
| Reviewer | Эпизодов | % от проверенных |
|
||
|---|---|---|
|
||
${rows}
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* brain-retro #7 C4 (2026-05-27): surface reviewer findings in STATUS.md.
|
||
*
|
||
* Owner reported "I don't notice wrong_skill cases between retros". This block
|
||
* gives the dashboard a per-period view of:
|
||
* - error_root_cause histogram (wrong_skill / wrong_chain_order / external_failure / n/a)
|
||
* - top alternative_better suggestions from reviewer
|
||
* - node_quality histogram (correct / disputable / wrong_node)
|
||
*
|
||
* Read-only, deterministic, pure. No filtering by time — operates on whatever
|
||
* episodes are loaded by the caller (current month per loadCurrentMonthEpisodes).
|
||
*/
|
||
export function computeReviewerFindingsBlock(episodes) {
|
||
// Detect "reviewed" via either signal:
|
||
// - ep.review.reviewed_at (subagent / canonical schema), OR
|
||
// - ep.outcome_reviewed_source (batch-reviewer: "direct_api_batch" / "direct_api_fallback"),
|
||
// while excluding any episode whose reviewer reported an error.
|
||
const reviewed = (episodes || []).filter((ep) => {
|
||
if (!ep?.review || ep?.review?.reviewer_error) return false;
|
||
if (ep.review.reviewed_at) return true;
|
||
if (typeof ep.outcome_reviewed_source === 'string' && ep.outcome_reviewed_source.startsWith('direct_api')) return true;
|
||
if (typeof ep.outcome_reviewed_source === 'string' && ep.outcome_reviewed_source === 'subagent') return true;
|
||
return false;
|
||
});
|
||
|
||
if (reviewed.length === 0) {
|
||
return `## Reviewer findings
|
||
|
||
(нет проверенных эпизодов в текущем периоде)
|
||
`;
|
||
}
|
||
|
||
const causeCounts = {};
|
||
const altCounts = {};
|
||
const qualityCounts = {};
|
||
let actionable = 0;
|
||
|
||
for (const ep of reviewed) {
|
||
const r = ep.review || {};
|
||
const cause = r.error_root_cause || 'n/a';
|
||
causeCounts[cause] = (causeCounts[cause] || 0) + 1;
|
||
if (cause === 'wrong_skill' || cause === 'wrong_chain_order') actionable++;
|
||
if (r.alternative_better) {
|
||
altCounts[r.alternative_better] = (altCounts[r.alternative_better] || 0) + 1;
|
||
}
|
||
const q = r.node_quality || 'unknown';
|
||
qualityCounts[q] = (qualityCounts[q] || 0) + 1;
|
||
}
|
||
|
||
if (actionable === 0) {
|
||
// No wrong_skill / wrong_chain_order — surface short "all clean" line.
|
||
return `## Reviewer findings
|
||
|
||
Проверено: ${reviewed.length}. **Всё чисто** — нет wrong_skill / wrong_chain_order.
|
||
`;
|
||
}
|
||
|
||
const causeRows = Object.entries(causeCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([k, v]) => `| ${k} | ${v} |`)
|
||
.join('\n');
|
||
|
||
const altRows = Object.entries(altCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.map(([k, v]) => `| ${k} | ${v} |`)
|
||
.join('\n');
|
||
|
||
const qualityRows = Object.entries(qualityCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([k, v]) => `| ${k} | ${v} |`)
|
||
.join('\n');
|
||
|
||
return `## Reviewer findings
|
||
|
||
Проверено: ${reviewed.length} эпизодов. **${actionable} actionable** (wrong_skill + wrong_chain_order).
|
||
|
||
### error_root_cause
|
||
|
||
| cause | count |
|
||
|---|---:|
|
||
${causeRows}
|
||
|
||
### Топ alternative_better
|
||
|
||
| recommended | count |
|
||
|---|---:|
|
||
${altRows || '| — | 0 |'}
|
||
|
||
### node_quality
|
||
|
||
| judgment | count |
|
||
|---|---:|
|
||
${qualityRows}
|
||
`;
|
||
}
|
||
|
||
export function renderStatus(inputs) {
|
||
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo, discipline } = inputs;
|
||
const c6 = inputs.c6 || { status: 'ok', detail: '—' };
|
||
const missed = inputs.missed || { totalMissed: 0, byNode: {}, byClassification: {} };
|
||
|
||
function formatPercent(p) { return `${(p * 100).toFixed(1)}%`; }
|
||
|
||
let disciplineBlock = '';
|
||
if (discipline) {
|
||
const rows = Object.entries(discipline.byClassification || {})
|
||
.sort((a, b) => b[1].episodes - a[1].episodes)
|
||
.map(([cls, b]) => `| ${cls} | ${b.episodes} | ${formatPercent(b.pctTriggerMatch)} | ${formatPercent(b.pctViaSkill)} |`)
|
||
.join('\n');
|
||
const stepDist = Object.entries(discipline.routerStep?.distribution || {})
|
||
.map(([k, v]) => `${k}: ${v}`).join(', ');
|
||
const suspicious = discipline.routerStep?.suspicious
|
||
? ' ⚠️ suspicious — >90% эпизодов остановились на step=1 (вероятный sentinel-bug парсера)'
|
||
: '';
|
||
const boundariesPct = formatPercent(discipline.boundariesRate?.rate || 0);
|
||
disciplineBlock = `
|
||
## Метрики дисциплины
|
||
|
||
Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3.
|
||
|
||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||
|---|---|---|---|
|
||
${rows || '| (no data) | 0 | 0% | 0% |'}
|
||
|
||
Router step distribution: ${stepDist || '(empty)'}${suspicious}
|
||
|
||
Boundaries applied (ADR / границы): ${discipline.boundariesRate?.withBoundaries || 0} of ${discipline.boundariesRate?.total || 0} эпизодов (${boundariesPct}).
|
||
`;
|
||
}
|
||
|
||
const activeProjects = (inputs.activeProjects || '').trim();
|
||
const projectsBlock = activeProjects
|
||
? `\n## Активные многоэтапные проекты\n\n${activeProjects}\n`
|
||
: '';
|
||
const retroLine = (lastRetroDaysAgo === null || lastRetroDaysAgo === undefined)
|
||
? 'never'
|
||
: `${lastRetroDaysAgo} day(s) ago`;
|
||
return `# Brain Status (auto-generated)
|
||
|
||
Last updated: ${now}
|
||
|
||
| Контролёр | Состояние | Детали |
|
||
|---|---|---|
|
||
| C1 L1-watcher | ${iconFor(c1.status)} | ${c1.detail || '—'} |
|
||
| C2 Cross-ref consistency | ${iconFor(c2.status)} | ${c2.detail || '—'} |
|
||
| C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} |
|
||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||
| C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} |
|
||
| C6 Chain map sync | ${iconFor(c6.status)} | ${c6.detail || '—'} |
|
||
${inputs.guardBoardBlock ? `\n${inputs.guardBoardBlock.trim()}\n` : ''}
|
||
## Метрики (информационные, не алерты)
|
||
|
||
- Observer evidence: ${observer.episodeCount} episodes this month, ${observer.observerErrors} observer_error markers, ${observer.piiMatches} PII matches before filter
|
||
- Legacy v1 episodes (not in factor analysis): ${observer.v1Episodes || 0}
|
||
- Last /brain-retro: ${retroLine}
|
||
- Использование узлов: см. \`/brain-retro\` (раз в спринт). missed_activations: ${missed.totalMissed}. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory \`feedback_brain_unused_tools_not_problem\` — outside-repo memory store).
|
||
${disciplineBlock}${projectsBlock}${inputs.sessionLengthBlock ? `\n${inputs.sessionLengthBlock.trim()}\n` : ''}${inputs.costBlock ? `\n${inputs.costBlock.trim()}\n` : ''}${inputs.anomalyBlock ? `\n${inputs.anomalyBlock.trim()}\n` : ''}${inputs.selfRetrospectBlock ? `\n${inputs.selfRetrospectBlock.trim()}\n` : ''}${inputs.reviewerBlock ? `\n${inputs.reviewerBlock.trim()}\n` : ''}${inputs.reviewerFindingsBlock ? `\n${inputs.reviewerFindingsBlock.trim()}\n` : ''}${inputs.overrideUsageBlock ? `\n${inputs.overrideUsageBlock.trim()}\n` : ''}${inputs.systemHealthBlock ? `\n${inputs.systemHealthBlock.trim()}\n` : ''}${inputs.learningQueueBlock ? `\n${inputs.learningQueueBlock.trim()}\n` : ''}${inputs.doorCoverageBlock ? `\n${inputs.doorCoverageBlock.trim()}\n` : ''}${inputs.journalIntegrityBlock ? `\n${inputs.journalIntegrityBlock.trim()}\n` : ''}
|
||
## Алерт-индикаторы
|
||
|
||
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
|
||
`;
|
||
}
|
||
|
||
function runControllerNode(scriptArgs) {
|
||
try {
|
||
const out = execFileSync('node', scriptArgs, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||
return { status: 'ok', detail: out.trim().split('\n').pop() };
|
||
} catch (err) {
|
||
return { status: 'fail', detail: (err.stderr || err.message || '').trim().split('\n').pop() };
|
||
}
|
||
}
|
||
|
||
// Config-seam (Task 7): расположение журнала наблюдателя. Дефолт docs/observer
|
||
// (backward-compat); CLI-блок переопределяет из brain.local.md (resolveStateDir).
|
||
let STATE_DIR = 'docs/observer';
|
||
|
||
function countEpisodes() {
|
||
const dir = STATE_DIR;
|
||
if (!existsSync(dir)) return 0;
|
||
const month = new Date().toISOString().slice(0, 7);
|
||
const file = join(dir, `episodes-${month}.jsonl`);
|
||
if (!existsSync(file)) return 0;
|
||
return readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean).length;
|
||
}
|
||
|
||
function countPiiMatches() {
|
||
const file = join(STATE_DIR,'.pii-counters.json');
|
||
if (!existsSync(file)) return 0;
|
||
try {
|
||
const data = JSON.parse(readFileSync(file, 'utf-8'));
|
||
const month = new Date().toISOString().slice(0, 7);
|
||
const monthCounts = data[month] || {};
|
||
return Object.values(monthCounts).reduce((s, n) => s + n, 0);
|
||
} catch { return 0; }
|
||
}
|
||
|
||
function lastRetroDaysAgo() {
|
||
const file = join(STATE_DIR,'.read-counter.json');
|
||
if (!existsSync(file)) return null;
|
||
try {
|
||
const data = JSON.parse(readFileSync(file, 'utf-8'));
|
||
if (!data.last_read_at) return null;
|
||
return Math.floor((Date.now() - new Date(data.last_read_at).getTime()) / 86400000);
|
||
} catch { return null; }
|
||
}
|
||
|
||
function countObserverErrors() {
|
||
const dir = STATE_DIR;
|
||
if (!existsSync(dir)) return 0;
|
||
const month = new Date().toISOString().slice(0, 7);
|
||
const file = join(dir, `episodes-${month}.jsonl`);
|
||
if (!existsSync(file)) return 0;
|
||
return readFileSync(file, 'utf-8')
|
||
.trim()
|
||
.split('\n')
|
||
.filter((l) => l.includes('"observer_error":true')).length;
|
||
}
|
||
|
||
/** Legacy v1 episode count — lines without schema_version=2, not observer_error markers. */
|
||
function countV1Episodes() {
|
||
const dir = STATE_DIR;
|
||
if (!existsSync(dir)) return 0;
|
||
const month = new Date().toISOString().slice(0, 7);
|
||
const file = join(dir, `episodes-${month}.jsonl`);
|
||
if (!existsSync(file)) return 0;
|
||
return readFileSync(file, 'utf-8')
|
||
.trim()
|
||
.split('\n')
|
||
.filter((l) => l && !l.includes('"schema_version":2') && !l.includes('"observer_error":true')).length;
|
||
}
|
||
|
||
function loadCurrentMonthEpisodes() {
|
||
const month = new Date().toISOString().slice(0, 7);
|
||
const file = join(STATE_DIR,`episodes-${month}.jsonl`);
|
||
if (!existsSync(file)) return [];
|
||
const out = [];
|
||
for (const line of readFileSync(file, 'utf-8').split('\n')) {
|
||
const t = line.trim();
|
||
if (!t) continue;
|
||
try { out.push(JSON.parse(t)); } catch { /* skip */ }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-generator.mjs')) {
|
||
try {
|
||
const { loadConfig, resolveStateDir } = await import('./brain-config.mjs');
|
||
STATE_DIR = resolveStateDir(loadConfig().state_dir).stateDir;
|
||
} catch { /* brain-config недоступен → STATE_DIR остаётся docs/observer */ }
|
||
const cov = runCoverageChecker();
|
||
const c5ok = cov.coverage.ok && cov.registration.ok && cov.missed.totalMissed === 0;
|
||
const c5detail = [
|
||
cov.coverage.detail,
|
||
cov.registration.detail,
|
||
cov.missed.totalMissed > 0 ? `${cov.missed.totalMissed} missed activation(s) — see /brain-retro` : null,
|
||
].filter(Boolean).join(' · ');
|
||
const inputs = {
|
||
now: new Date().toISOString(),
|
||
c1: runControllerNode(['tools/l1-watcher.mjs']),
|
||
c2: runControllerNode(['tools/cross-ref-checker.mjs']),
|
||
c3: runControllerNode(['tools/observer-of-observer.mjs', 'check']),
|
||
c5: { status: c5ok ? 'ok' : 'warn', detail: c5detail },
|
||
c6: runControllerNode(['tools/observer-chain-map-checker.mjs']),
|
||
observer: {
|
||
episodeCount: countEpisodes(),
|
||
observerErrors: countObserverErrors(),
|
||
piiMatches: countPiiMatches(),
|
||
v1Episodes: countV1Episodes(),
|
||
},
|
||
missed: cov.missed,
|
||
lastRetroDaysAgo: lastRetroDaysAgo(),
|
||
activeProjects: existsSync(join(STATE_DIR, 'active-projects.md'))
|
||
? readFileSync(join(STATE_DIR, 'active-projects.md'), 'utf-8')
|
||
: '',
|
||
discipline: (() => {
|
||
try {
|
||
const registry = loadRegistry({ useCache: false });
|
||
const classificationMap = buildClassificationMap(registry);
|
||
const dormancy = buildDormancyMap(registry);
|
||
const eps = loadCurrentMonthEpisodes();
|
||
const a = analyze(eps, { classificationMap, dormancy });
|
||
return {
|
||
byClassification: a.disciplineByClassification,
|
||
routerStep: a.routerStep,
|
||
boundariesRate: a.boundariesRate,
|
||
};
|
||
} catch (err) {
|
||
console.warn('[status-md-generator] discipline calc skipped:', err.message);
|
||
return null;
|
||
}
|
||
})(),
|
||
};
|
||
|
||
const eps = loadCurrentMonthEpisodes();
|
||
let costBlock = null, anomalyBlock = null, selfRetrospectBlock = null, reviewerBlock = null, reviewerFindingsBlock = null, sessionLengthBlock = null, overrideUsageBlock = null;
|
||
try { costBlock = computeCostBlock(eps, PRICING); } catch (err) { console.warn('[status-md-generator] costBlock skipped:', err.message); costBlock = '(нет данных)'; }
|
||
try { anomalyBlock = computeAnomalyBlock(eps); } catch (err) { console.warn('[status-md-generator] anomalyBlock skipped:', err.message); anomalyBlock = '(нет данных)'; }
|
||
try { selfRetrospectBlock = computeSelfRetrospectBlock(join(STATE_DIR,'.self-retrospect-counter.json')); } catch (err) { console.warn('[status-md-generator] selfRetrospectBlock skipped:', err.message); selfRetrospectBlock = '(нет данных)'; }
|
||
try { reviewerBlock = computeReviewerBlock(eps); } catch (err) { console.warn('[status-md-generator] reviewerBlock skipped:', err.message); reviewerBlock = '(нет данных)'; }
|
||
try { reviewerFindingsBlock = computeReviewerFindingsBlock(eps); } catch (err) { console.warn('[status-md-generator] reviewerFindingsBlock skipped:', err.message); reviewerFindingsBlock = '(нет данных)'; }
|
||
try { sessionLengthBlock = computeSessionLengthBlock(eps); } catch (err) { console.warn('[status-md-generator] sessionLengthBlock skipped:', err.message); sessionLengthBlock = '(нет данных)'; }
|
||
try {
|
||
const logPath = join(homedir(), '.claude', 'runtime', 'override-usage.jsonl');
|
||
const raw = existsSync(logPath) ? readFileSync(logPath, 'utf-8') : '';
|
||
overrideUsageBlock = computeOverrideUsageBlock(raw);
|
||
} catch (err) { console.warn('[status-md-generator] overrideUsageBlock skipped:', err.message); overrideUsageBlock = '(нет данных)'; }
|
||
inputs.costBlock = costBlock;
|
||
inputs.anomalyBlock = anomalyBlock;
|
||
inputs.selfRetrospectBlock = selfRetrospectBlock;
|
||
inputs.reviewerBlock = reviewerBlock;
|
||
inputs.reviewerFindingsBlock = reviewerFindingsBlock;
|
||
inputs.sessionLengthBlock = sessionLengthBlock;
|
||
inputs.overrideUsageBlock = overrideUsageBlock;
|
||
|
||
let systemHealthBlock = null;
|
||
try { systemHealthBlock = computeSystemHealthBlock(queryRunningProcesses()); } catch (err) { console.warn('[status-md-generator] systemHealthBlock skipped:', err.message); systemHealthBlock = null; }
|
||
inputs.systemHealthBlock = systemHealthBlock;
|
||
|
||
let learningQueueBlock = null;
|
||
try {
|
||
const qPath = join(homedir(), '.claude', 'runtime', 'router-learning-queue.json');
|
||
const queue = existsSync(qPath) ? loadQueue({ path: qPath }) : [];
|
||
learningQueueBlock = computeLearningQueueBlock({ queue });
|
||
} catch (err) { console.warn('[status-md-generator] learningQueueBlock skipped:', err.message); learningQueueBlock = null; }
|
||
inputs.learningQueueBlock = learningQueueBlock;
|
||
|
||
let doorCoverageBlock = null;
|
||
try {
|
||
let settings = {};
|
||
try { settings = JSON.parse(readFileSync('.claude/settings.json', 'utf-8')); } catch { settings = {}; }
|
||
doorCoverageBlock = computeDoorCoverageBlock({ settings });
|
||
} catch (err) { console.warn('[status-md-generator] doorCoverageBlock skipped:', err.message); doorCoverageBlock = null; }
|
||
inputs.doorCoverageBlock = doorCoverageBlock;
|
||
|
||
let journalIntegrityBlock = null;
|
||
try {
|
||
const runtimeDir = join(homedir(), '.claude', 'runtime');
|
||
const key = resolveReceiptKey();
|
||
if (key === null || key === undefined) {
|
||
journalIntegrityBlock = computeJournalIntegrityBlock({ keyAvailable: false });
|
||
} else {
|
||
const files = existsSync(runtimeDir) ? readdirSync(runtimeDir) : [];
|
||
const results = [];
|
||
for (const f of files) {
|
||
const m = /^action-journal-(.+)\.jsonl$/.exec(f);
|
||
if (!m) continue;
|
||
const sessionId = m[1];
|
||
try {
|
||
const { entries, headSig } = loadJournal({ sessionId, runtimeDir });
|
||
const v = verifyChain(entries, headSig, { key });
|
||
results.push({ sessionId, ok: v.ok, brokenAt: v.brokenAt });
|
||
} catch { results.push({ sessionId, ok: false, brokenAt: null }); }
|
||
}
|
||
journalIntegrityBlock = computeJournalIntegrityBlock({ results, keyAvailable: true });
|
||
}
|
||
} catch (err) { console.warn('[status-md-generator] journalIntegrityBlock skipped:', err.message); journalIntegrityBlock = null; }
|
||
inputs.journalIntegrityBlock = journalIntegrityBlock;
|
||
|
||
// §7 доска «кто на посту»: манифест из .claude/settings.json (read-only). Судья М4 пока inert
|
||
// (live-режим — Ф7 ИИ-проводка); escape/блоки — детализация follow-up (счётчики []).
|
||
let guardBoardBlock = null;
|
||
try {
|
||
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: loadRecentEscapes(), recentBlocks: loadRecentBlocks() });
|
||
} catch (err) { console.warn('[status-md-generator] guardBoardBlock skipped:', err.message); guardBoardBlock = null; }
|
||
inputs.guardBoardBlock = guardBoardBlock;
|
||
|
||
const md = renderStatus(inputs);
|
||
writeFileSync(join(STATE_DIR, 'STATUS.md'), md);
|
||
console.log(`[status-md-generator] OK — wrote ${join(STATE_DIR, 'STATUS.md')}`);
|
||
}
|