Files
brain/tools/status-md-generator.mjs
T
2026-06-15 19:39:59 +03:00

725 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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')}`);
}