Files
portal/tools/status-md-generator.mjs
T
Дмитрий db0cde0593 feat(status-md): add C6 System Health block
Surfaces top-3 long-running processes (CPU > 1h) in STATUS.md dashboard.
Closes brain-retro #9 sanity-Q2 — observer was blind to orphan background
processes (e.g. PID 6444 python adr-judge spinning 7h+ undetected).

Read-only PowerShell Get-Process probe with 5s timeout; gracefully degrades
on non-Windows OS (returns empty array).

Closes brain-retro #9 candidate 5.
2026-05-28 10:45:45 +03:00

535 lines
22 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } 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';
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] || '⚪';
}
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 || '—'} |
## Метрики (информационные, не алерты)
- 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` : ''}
## Алерт-индикаторы
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
`;
}
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() };
}
}
function countEpisodes() {
const dir = 'docs/observer';
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('docs', 'observer', '.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('docs', 'observer', '.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 = 'docs/observer';
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 = 'docs/observer';
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('docs', 'observer', `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')) {
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('docs/observer/active-projects.md')
? readFileSync('docs/observer/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('docs', 'observer', '.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;
const md = renderStatus(inputs);
writeFileSync('docs/observer/STATUS.md', md);
console.log(`[status-md-generator] OK — wrote docs/observer/STATUS.md`);
}