2026-04-21 17:57:05 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 全链路健康检查 (Health Check)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 聚合所有验证器为单一健康评分 (0-100)。
|
|
|
|
|
|
* 供 self-auditor D8 维度和日常运维使用。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用法:
|
|
|
|
|
|
* node scripts/health-check.js # 文本报告
|
|
|
|
|
|
* node scripts/health-check.js --json # JSON 输出 (供程序消费)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const { execSync } = require('child_process');
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
|
|
|
|
|
|
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
|
|
|
|
|
|
|
|
|
|
|
|
const CLAUDE_ROOT = detectClaudeRoot();
|
|
|
|
|
|
const JSON_MODE = process.argv.includes('--json');
|
|
|
|
|
|
|
2026-04-27 17:59:44 +08:00
|
|
|
|
// W7_SAFE_AGE_v1: 时间回拨防护 (NTP 回跳/跨时区/休眠恢复)
|
|
|
|
|
|
function safeAge(t) {
|
|
|
|
|
|
const ts = typeof t === 'number' ? t : (t && t.getTime ? t.getTime() : 0);
|
|
|
|
|
|
const d = Date.now() - ts;
|
|
|
|
|
|
return d < 0 ? 0 : d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 17:57:05 +08:00
|
|
|
|
// === 维度检查器 ===
|
|
|
|
|
|
let dimensions = [];
|
|
|
|
|
|
function resetDimensions() { dimensions = []; return dimensions; }
|
|
|
|
|
|
|
|
|
|
|
|
function addDimension(id, name, score, status, detail) {
|
|
|
|
|
|
dimensions.push({ id, name, score, status, detail });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runScript(scriptPath, args = '') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = execSync(
|
|
|
|
|
|
`node "${path.join(CLAUDE_ROOT, scriptPath)}" ${args}`,
|
|
|
|
|
|
{ encoding: 'utf8', timeout: 15000, cwd: CLAUDE_ROOT }
|
|
|
|
|
|
);
|
|
|
|
|
|
return { ok: true, output: result };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, output: e.stdout || '', error: e.stderr || e.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H1: 配置一致性 (config-validator)
|
|
|
|
|
|
function checkConfig() {
|
|
|
|
|
|
const result = runScript('scripts/config-validator.js', '--json');
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
addDimension('H1', '配置一致性', 50, 'WARN', '验证器执行失败');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(result.output);
|
|
|
|
|
|
if (data.errors > 0) {
|
|
|
|
|
|
addDimension('H1', '配置一致性', Math.max(0, 100 - data.errors * 25 - data.warnings * 5),
|
|
|
|
|
|
'FAIL', `${data.errors} errors, ${data.warnings} warnings`);
|
|
|
|
|
|
} else if (data.warnings > 0) {
|
|
|
|
|
|
addDimension('H1', '配置一致性', Math.max(70, 100 - data.warnings * 5),
|
|
|
|
|
|
'WARN', `${data.warnings} warnings`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H1', '配置一致性', 100, 'PASS', '全部检查通过');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H1', '配置一致性', 60, 'WARN', '输出解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H2: 行为基线 (behavior-baseline)
|
|
|
|
|
|
function checkBehavior() {
|
|
|
|
|
|
const result = runScript('scripts/behavior-baseline.js', '--check --json');
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
|
addDimension('H2', '行为基线', 80, 'INFO', '基线数据不足');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(result.output);
|
|
|
|
|
|
const check = data.check || {};
|
|
|
|
|
|
const anomalyCount = (check.anomalies || []).length;
|
|
|
|
|
|
const criticals = (check.anomalies || []).filter(a => a.severity === 'CRITICAL').length;
|
|
|
|
|
|
|
|
|
|
|
|
if (criticals > 0) {
|
|
|
|
|
|
addDimension('H2', '行为基线', 30, 'FAIL', `${criticals} 个严重异常`);
|
|
|
|
|
|
} else if (anomalyCount > 0) {
|
|
|
|
|
|
addDimension('H2', '行为基线', Math.max(60, 100 - anomalyCount * 15),
|
|
|
|
|
|
'WARN', `${anomalyCount} 个异常`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H2', '行为基线', 100, 'PASS', '行为正常');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H2', '行为基线', 80, 'INFO', '输出解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H3: 磁盘健康
|
|
|
|
|
|
function checkDisk() {
|
|
|
|
|
|
const debugDir = path.join(CLAUDE_ROOT, 'debug');
|
|
|
|
|
|
let totalSize = 0;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const files = fs.readdirSync(debugDir);
|
|
|
|
|
|
for (const f of files) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
totalSize += fs.statSync(path.join(debugDir, f)).size;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
const sizeMB = Math.round(totalSize / 1024 / 1024);
|
|
|
|
|
|
|
|
|
|
|
|
// P5: session-active.lock 过期检测 (>24h 视为残留)
|
|
|
|
|
|
let lockNote = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const lockFile = path.join(debugDir, 'session-active.lock');
|
|
|
|
|
|
if (fs.existsSync(lockFile)) {
|
2026-04-27 17:59:44 +08:00
|
|
|
|
const lockAge = safeAge(fs.statSync(lockFile).mtimeMs) / 3600000;
|
2026-04-21 17:57:05 +08:00
|
|
|
|
if (lockAge > 24) {
|
|
|
|
|
|
lockNote = `, session-active.lock 残留 (${Math.round(lockAge)}h 前)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
if (sizeMB > 16384) {
|
|
|
|
|
|
addDimension('H3', '磁盘健康', 20, 'FAIL', `${sizeMB} MB (>16GB CRITICAL)${lockNote}`);
|
|
|
|
|
|
} else if (sizeMB > 8192) {
|
|
|
|
|
|
addDimension('H3', '磁盘健康', 50, 'WARN', `${sizeMB} MB (>8GB WARNING)${lockNote}`);
|
|
|
|
|
|
} else if (sizeMB > 1024) {
|
|
|
|
|
|
addDimension('H3', '磁盘健康', 75, 'INFO', `${sizeMB} MB${lockNote}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H3', '磁盘健康', lockNote ? 90 : 100, lockNote ? 'INFO' : 'PASS', `${sizeMB} MB${lockNote}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H4: 钩子完整性 (checksums + designDecisions 识别)
|
|
|
|
|
|
function checkIntegrity() {
|
|
|
|
|
|
const checksumFile = path.join(CLAUDE_ROOT, 'hooks', 'checksums.json');
|
|
|
|
|
|
if (!fs.existsSync(checksumFile)) {
|
|
|
|
|
|
addDimension('H4', '钩子完整性', 60, 'WARN', 'checksums.json 不存在');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
const checksums = JSON.parse(fs.readFileSync(checksumFile, 'utf8'));
|
|
|
|
|
|
const hooksDir = path.join(CLAUDE_ROOT, 'hooks');
|
|
|
|
|
|
let total = 0, match = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [file, expectedHash] of Object.entries(checksums)) {
|
|
|
|
|
|
total++;
|
|
|
|
|
|
// scripts/ 前缀文件从 CLAUDE_ROOT 解析,其余从 hooksDir 解析
|
|
|
|
|
|
const filePath = file.startsWith('scripts/') ? path.join(CLAUDE_ROOT, file) : path.join(hooksDir, file);
|
|
|
|
|
|
if (!fs.existsSync(filePath)) continue;
|
|
|
|
|
|
const content = fs.readFileSync(filePath);
|
|
|
|
|
|
const actualHash = crypto.createHash('sha256').update(content).digest('hex');
|
|
|
|
|
|
if (actualHash === expectedHash) match++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 读取 stats-compiled.json 的 designDecisions,识别有意未注册的备用钩子
|
|
|
|
|
|
let designNote = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const statsFile = path.join(CLAUDE_ROOT, 'stats-compiled.json');
|
|
|
|
|
|
if (fs.existsSync(statsFile)) {
|
|
|
|
|
|
const stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
|
|
|
|
const dd = stats.designDecisions || {};
|
|
|
|
|
|
if (dd.unregisteredHooksIntentional && (stats.unregisteredHooks || []).length > 0) {
|
|
|
|
|
|
designNote = `, ${stats.unregisteredHooks.length} 备用钩子 (有意未注册)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* stats 不可用时静默回退 */ }
|
|
|
|
|
|
|
|
|
|
|
|
// 检测 settings.json 注册的钩子文件是否实际存在
|
|
|
|
|
|
let missingHookFiles = 0;
|
|
|
|
|
|
let hookFileNotes = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const settingsPath = path.join(CLAUDE_ROOT, 'settings.json');
|
|
|
|
|
|
if (fs.existsSync(settingsPath)) {
|
|
|
|
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
|
|
|
|
const hooksConfig = settings.hooks || {};
|
|
|
|
|
|
const checkedPaths = new Set(); // 去重,同一路径只检查一次
|
|
|
|
|
|
|
|
|
|
|
|
// 遍历所有 hook 事件类型(UserPromptSubmit, PreToolUse 等)
|
|
|
|
|
|
for (const eventHookGroups of Object.values(hooksConfig)) {
|
|
|
|
|
|
if (!Array.isArray(eventHookGroups)) continue;
|
|
|
|
|
|
for (const group of eventHookGroups) {
|
|
|
|
|
|
const hookList = group.hooks || [];
|
|
|
|
|
|
for (const hook of hookList) {
|
|
|
|
|
|
if (hook.type !== 'command' || !hook.command) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 从 command 字段提取文件路径(格式: "node path/to/file.js [args]")
|
|
|
|
|
|
const cmdParts = hook.command.split(/\s+/);
|
|
|
|
|
|
// 跳过 "node",找到 .js 文件路径
|
|
|
|
|
|
const jsFile = cmdParts.find((p, i) => i > 0 && p.endsWith('.js'));
|
|
|
|
|
|
if (!jsFile) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 清理路径:移除可能的引号包裹
|
|
|
|
|
|
const cleanPath = jsFile.replace(/^["']|["']$/g, '');
|
|
|
|
|
|
if (checkedPaths.has(cleanPath)) continue;
|
|
|
|
|
|
checkedPaths.add(cleanPath);
|
|
|
|
|
|
|
|
|
|
|
|
// 解析为绝对路径
|
|
|
|
|
|
const absPath = path.isAbsolute(cleanPath)
|
|
|
|
|
|
? cleanPath
|
|
|
|
|
|
: path.join(CLAUDE_ROOT, cleanPath);
|
|
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(absPath)) {
|
|
|
|
|
|
// 检查是否存在 .disabled 版本
|
|
|
|
|
|
const disabledPath = absPath + '.disabled';
|
|
|
|
|
|
if (fs.existsSync(disabledPath)) {
|
|
|
|
|
|
hookFileNotes.push(`${path.basename(absPath)} 已 .disabled`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
missingHookFiles++;
|
|
|
|
|
|
hookFileNotes.push(`settings.json 注册了 ${path.basename(absPath)} 但文件不存在`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* settings.json 读取异常时 fail-open,不影响其他维度 */ }
|
|
|
|
|
|
|
|
|
|
|
|
// 将 hookFileNotes 追加到详情
|
|
|
|
|
|
const hookFileDetail = hookFileNotes.length > 0 ? `, ${hookFileNotes.join('; ')}` : '';
|
|
|
|
|
|
|
|
|
|
|
|
// 综合评分:checksums 匹配度 + 缺失钩子文件扣分(每缺失一个扣 5 分)
|
|
|
|
|
|
const checksumScore = total > 0 ? Math.round(match / total * 100) : 100;
|
|
|
|
|
|
const hookFilePenalty = missingHookFiles * 5;
|
|
|
|
|
|
const finalScore = Math.max(0, Math.min(checksumScore, 100) - hookFilePenalty);
|
|
|
|
|
|
|
|
|
|
|
|
if (match === total && missingHookFiles === 0) {
|
|
|
|
|
|
addDimension('H4', '钩子完整性', finalScore, 'PASS', `${total}/${total} 校验通过${designNote}${hookFileDetail}`);
|
|
|
|
|
|
} else if (match === total && missingHookFiles > 0) {
|
|
|
|
|
|
addDimension('H4', '钩子完整性', finalScore, 'WARN', `${total}/${total} 校验通过, ${missingHookFiles} 个注册钩子文件缺失${designNote}${hookFileDetail}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H4', '钩子完整性', Math.max(0, finalScore),
|
|
|
|
|
|
'FAIL', `${match}/${total} 校验通过, ${total - match} 个不匹配${missingHookFiles > 0 ? `, ${missingHookFiles} 个注册钩子文件缺失` : ''}${designNote}${hookFileDetail}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
addDimension('H4', '钩子完整性', 50, 'WARN', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H5: 技能索引新鲜度
|
|
|
|
|
|
function checkSkillIndex() {
|
|
|
|
|
|
const indexFile = path.join(CLAUDE_ROOT, 'skills-index.json');
|
|
|
|
|
|
if (!fs.existsSync(indexFile)) {
|
|
|
|
|
|
addDimension('H5', '技能索引', 50, 'WARN', '索引文件不存在');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
|
|
|
|
const skillsDir = path.join(CLAUDE_ROOT, 'skills');
|
|
|
|
|
|
const actualCount = fs.readdirSync(skillsDir)
|
|
|
|
|
|
.filter(d => fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))).length;
|
|
|
|
|
|
|
|
|
|
|
|
if (index.skillCount === actualCount) {
|
|
|
|
|
|
addDimension('H5', '技能索引', 100, 'PASS', `${actualCount} 技能已索引`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H5', '技能索引', 70, 'WARN',
|
|
|
|
|
|
`索引 ${index.skillCount} vs 实际 ${actualCount}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H5', '技能索引', 60, 'WARN', '索引解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H6: 规则缓存
|
|
|
|
|
|
function checkRulesCache() {
|
|
|
|
|
|
const compiled = path.join(CLAUDE_ROOT, 'hooks', 'rules', 'rules-compiled.json');
|
|
|
|
|
|
if (!fs.existsSync(compiled)) {
|
|
|
|
|
|
addDimension('H6', '规则缓存', 70, 'INFO', '无预编译缓存 (性能未优化)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cache = JSON.parse(fs.readFileSync(compiled, 'utf8'));
|
|
|
|
|
|
const rulesDir = path.join(CLAUDE_ROOT, 'hooks', 'rules');
|
|
|
|
|
|
let stale = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [file, meta] of Object.entries(cache.sources || {})) {
|
|
|
|
|
|
const filePath = path.join(rulesDir, file);
|
|
|
|
|
|
if (!fs.existsSync(filePath)) { stale = true; break; }
|
|
|
|
|
|
const stat = fs.statSync(filePath);
|
|
|
|
|
|
if (Math.abs(stat.mtimeMs - meta.mtime) > 1000) { stale = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (stale) {
|
|
|
|
|
|
addDimension('H6', '规则缓存', 60, 'WARN', '缓存已过期 (运行 compile-rules.js)');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const totalPatterns = Object.values(cache.rules || {}).reduce((s, r) => s + r.length, 0);
|
|
|
|
|
|
addDimension('H6', '规则缓存', 100, 'PASS', `${totalPatterns} 条规则已缓存`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H6', '规则缓存', 60, 'WARN', '缓存解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H7: 路由准确率 (v5.0, P2 加固: 检测 feedback 文件缺失 + weights 过期)
|
|
|
|
|
|
function checkRouteAccuracy() {
|
|
|
|
|
|
const feedbackFile = path.join(CLAUDE_ROOT, 'debug', 'route-feedback.jsonl');
|
|
|
|
|
|
const weightsFile = path.join(CLAUDE_ROOT, 'debug', 'route-weights.json');
|
|
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(feedbackFile)) {
|
|
|
|
|
|
// P2: 如果 weights 存在但 feedback 缺失,说明学习闭环断裂
|
|
|
|
|
|
if (fs.existsSync(weightsFile)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const w = JSON.parse(fs.readFileSync(weightsFile, 'utf8'));
|
|
|
|
|
|
const genDate = w.generated ? new Date(w.generated) : null;
|
2026-04-27 17:59:44 +08:00
|
|
|
|
const daysSince = genDate ? Math.floor(safeAge(genDate) / 86400000) : -1;
|
2026-04-21 17:57:05 +08:00
|
|
|
|
addDimension('H7', '路由准确率', 50, 'WARN',
|
|
|
|
|
|
`学习闭环断裂: route-feedback.jsonl 缺失,权重固化于 ${daysSince >= 0 ? daysSince + ' 天前' : '未知时间'} (${w.feedbackCount || 0} 条历史反馈)`);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H7', '路由准确率', 50, 'WARN', '学习闭环断裂: feedback 文件缺失');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H7', '路由准确率', 80, 'INFO', '无反馈数据 (尚未收集)');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const lines = fs.readFileSync(feedbackFile, 'utf8').trim().split('\n');
|
|
|
|
|
|
const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
if (entries.length === 0) {
|
|
|
|
|
|
addDimension('H7', '路由准确率', 80, 'INFO', '无反馈数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const corrections = entries.filter(f => f.routedTo !== f.correctedTo && f.routedTo !== 'unknown');
|
|
|
|
|
|
const total = entries.filter(f => f.routedTo !== 'unknown').length;
|
|
|
|
|
|
const accuracy = total > 0 ? (total - corrections.length) / total : 1;
|
|
|
|
|
|
const pct = Math.round(accuracy * 100);
|
|
|
|
|
|
|
|
|
|
|
|
if (pct >= 90) {
|
|
|
|
|
|
addDimension('H7', '路由准确率', 100, 'PASS', `${pct}% (${total} 条反馈)`);
|
|
|
|
|
|
} else if (pct >= 70) {
|
|
|
|
|
|
addDimension('H7', '路由准确率', pct, 'WARN', `${pct}% (${corrections.length}/${total} 纠正)`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H7', '路由准确率', Math.max(30, pct), 'FAIL', `${pct}% 准确率过低`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H7', '路由准确率', 70, 'INFO', '数据解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H8: 学习收敛趋势 (v5.0, P2 加固: 权重过期检测)
|
|
|
|
|
|
function checkLearningConvergence() {
|
|
|
|
|
|
const weightsFile = path.join(CLAUDE_ROOT, 'debug', 'route-weights.json');
|
|
|
|
|
|
if (!fs.existsSync(weightsFile)) {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 70, 'INFO', '权重文件不存在 (未学习)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(fs.readFileSync(weightsFile, 'utf8'));
|
|
|
|
|
|
|
|
|
|
|
|
// P2: 权重过期检测 — 超过 7 天未更新则降级
|
|
|
|
|
|
const genDate = data.generated ? new Date(data.generated) : null;
|
2026-04-27 17:59:44 +08:00
|
|
|
|
const daysSince = genDate ? Math.floor(safeAge(genDate) / 86400000) : -1;
|
2026-04-21 17:57:05 +08:00
|
|
|
|
if (daysSince > 7) {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 60, 'WARN',
|
|
|
|
|
|
`权重已过期: ${daysSince} 天未更新 (generated: ${data.generated || '未知'})`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deltas = data.deltas || {};
|
|
|
|
|
|
const skillCount = Object.keys(deltas).length;
|
|
|
|
|
|
|
|
|
|
|
|
if (skillCount === 0) {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 90, 'PASS', '无权重偏移 (稳定)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分析权重偏移幅度
|
|
|
|
|
|
let totalAbsDelta = 0;
|
|
|
|
|
|
let totalEntries = 0;
|
|
|
|
|
|
let maxAbsDelta = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const skillDeltas of Object.values(deltas)) {
|
|
|
|
|
|
for (const delta of Object.values(skillDeltas)) {
|
|
|
|
|
|
totalAbsDelta += Math.abs(delta);
|
|
|
|
|
|
totalEntries++;
|
|
|
|
|
|
maxAbsDelta = Math.max(maxAbsDelta, Math.abs(delta));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const avgDelta = totalEntries > 0 ? totalAbsDelta / totalEntries : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 收敛判断: 平均偏移越小越好
|
|
|
|
|
|
if (avgDelta < 0.1 && maxAbsDelta < 0.3) {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 100, 'PASS',
|
|
|
|
|
|
`已收敛 (avg=${avgDelta.toFixed(3)}, max=${maxAbsDelta.toFixed(3)})`);
|
|
|
|
|
|
} else if (avgDelta < 0.2) {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 80, 'INFO',
|
|
|
|
|
|
`趋于收敛 (avg=${avgDelta.toFixed(3)}, ${totalEntries} 调整)`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 60, 'WARN',
|
|
|
|
|
|
`偏移较大 (avg=${avgDelta.toFixed(3)}, max=${maxAbsDelta.toFixed(3)})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H8', '学习收敛', 70, 'INFO', '权重文件解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H9: 路由合规率 (v5.2)
|
|
|
|
|
|
function checkRouteCompliance() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const complianceAnalyzer = require('./compliance-analyzer.js');
|
|
|
|
|
|
const entries = complianceAnalyzer.loadComplianceLogs(7);
|
|
|
|
|
|
const metrics = complianceAnalyzer.computeComplianceRate(entries);
|
|
|
|
|
|
|
|
|
|
|
|
if (metrics.total === 0) {
|
|
|
|
|
|
addDimension('H9', '路由合规率', 80, 'INFO', '无合规数据 (尚未收集)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pct = Math.round(metrics.rate * 100);
|
|
|
|
|
|
if (pct >= 95) {
|
|
|
|
|
|
addDimension('H9', '路由合规率', 100, 'PASS', `${pct}% (${metrics.total} 条决策)`);
|
|
|
|
|
|
} else if (pct >= 80) {
|
|
|
|
|
|
addDimension('H9', '路由合规率', pct, 'WARN', `${pct}% (${metrics.violated} 违规)`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H9', '路由合规率', Math.max(30, pct), 'FAIL', `${pct}% 合规率过低`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H9', '路由合规率', 70, 'INFO', '合规分析器不可用');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H10: Hook有效性 (v5.4 Phase 2)
|
|
|
|
|
|
function checkHookEffectiveness() {
|
|
|
|
|
|
const aggFile = path.join(CLAUDE_ROOT, 'debug', 'outcome-aggregation.json');
|
|
|
|
|
|
if (!fs.existsSync(aggFile)) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 80, 'INFO', '无聚合数据 (尚未收集)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const agg = JSON.parse(fs.readFileSync(aggFile, 'utf8'));
|
|
|
|
|
|
const commands = Object.keys(agg);
|
|
|
|
|
|
if (commands.length === 0) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 80, 'INFO', '无构建记录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算总体构建成功率 (unknown 不计入有效样本)
|
|
|
|
|
|
let totalBuilds = 0, totalSuccess = 0, totalUnknown = 0;
|
|
|
|
|
|
for (const cmd of commands) {
|
|
|
|
|
|
const entry = agg[cmd];
|
|
|
|
|
|
totalBuilds += entry.total || 0;
|
|
|
|
|
|
totalSuccess += entry.success || 0;
|
|
|
|
|
|
totalUnknown += entry.unknown || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (totalBuilds === 0) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 80, 'INFO', '无构建记录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 有效样本 = 总数 - unknown; 全部 unknown 时报 INFO 而非 FAIL
|
|
|
|
|
|
const effective = totalBuilds - totalUnknown;
|
|
|
|
|
|
if (effective === 0) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 80, 'INFO',
|
|
|
|
|
|
`${totalBuilds} 次构建均无法判定结果 (outcome tracker 未捕获退出码)`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const successRate = totalSuccess / effective;
|
|
|
|
|
|
const pct = Math.round(successRate * 100);
|
|
|
|
|
|
|
|
|
|
|
|
if (successRate >= 0.8) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 100, 'PASS', `构建成功率 ${pct}% (${totalSuccess}/${effective}, ${totalUnknown} unknown)`);
|
|
|
|
|
|
} else if (successRate >= 0.6) {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 75, 'WARN', `构建成功率 ${pct}% (${totalSuccess}/${effective})`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', Math.max(40, pct), 'FAIL', `构建成功率 ${pct}% (${totalSuccess}/${effective})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
addDimension('H10', 'Hook有效性', 80, 'INFO', '聚合数据解析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// H11: 宪法覆盖率 (v6.0)
|
|
|
|
|
|
function checkConstitutionCoverage() {
|
|
|
|
|
|
// 检查全局宪法副本存在性
|
|
|
|
|
|
const globalConstitution = path.join(CLAUDE_ROOT, 'constitution', 'AI-CONSTITUTION.md');
|
|
|
|
|
|
const globalTemplate = path.join(CLAUDE_ROOT, 'constitution', 'TEMPLATE-CONSTITUTION.md');
|
|
|
|
|
|
const claudeMd = path.join(CLAUDE_ROOT, 'CLAUDE.md');
|
|
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(globalConstitution)) {
|
|
|
|
|
|
addDimension('H11', '宪法覆盖', 30, 'FAIL', '全局宪法文件缺失 (~/.claude/constitution/AI-CONSTITUTION.md)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let score = 60; // 基线: 全局宪法存在
|
|
|
|
|
|
const details = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 检查全局 CLAUDE.md 包含交付质量宪章
|
|
|
|
|
|
try {
|
|
|
|
|
|
const claudeContent = (() => { try { return fs.readFileSync(claudeMd, 'utf8'); } catch { return ''; } })();
|
|
|
|
|
|
if (claudeContent.includes('交付质量宪章')) {
|
|
|
|
|
|
score += 15;
|
|
|
|
|
|
details.push('全局宪章已集成');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
details.push('全局 CLAUDE.md 缺少交付质量宪章');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (claudeContent.includes('项目宪法协议')) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查宪法模板存在
|
|
|
|
|
|
if (fs.existsSync(globalTemplate)) {
|
|
|
|
|
|
score += 5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查 constitution-guard hook 存在且启用
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ff = JSON.parse((() => { try { return fs.readFileSync(path.join(CLAUDE_ROOT, 'feature-flags.json'), 'utf8'); } catch { return '{}'; } })());
|
|
|
|
|
|
if (ff.features && ff.features['constitution-guard'] && ff.features['constitution-guard'].enabled) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
details.push('guard hook 已启用');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
details.push('guard hook 未启用');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
score = Math.min(100, score);
|
|
|
|
|
|
const status = score >= 90 ? 'PASS' : score >= 70 ? 'INFO' : 'WARN';
|
|
|
|
|
|
addDimension('H11', '宪法覆盖', score, status, details.join(', ') || '宪法体系已部署');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H12: Browserbase MCP 健康 (v6.5)
|
|
|
|
|
|
function checkBrowserbaseMcp() {
|
|
|
|
|
|
const wrapperPath = path.join(CLAUDE_ROOT, 'scripts', 'browserbase-mcp-wrapper.js');
|
|
|
|
|
|
const patchScript = path.join(CLAUDE_ROOT, 'mcp-servers', 'browserbase', 'postinstall-patch.js');
|
|
|
|
|
|
const bootstrapPath = path.join(CLAUDE_ROOT, 'scripts', 'undici-proxy-bootstrap.js');
|
|
|
|
|
|
const agentJs = path.join(CLAUDE_ROOT, 'mcp-servers', 'browserbase',
|
|
|
|
|
|
'node_modules', '@browserbasehq', 'mcp-server-browserbase', 'dist', 'tools', 'agent.js');
|
|
|
|
|
|
const stagehandIndex = path.join(CLAUDE_ROOT, 'mcp-servers', 'browserbase',
|
|
|
|
|
|
'node_modules', '@browserbasehq', 'stagehand', 'dist', 'index.js');
|
|
|
|
|
|
|
|
|
|
|
|
let score = 0;
|
|
|
|
|
|
const issues = [];
|
|
|
|
|
|
const passes = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Wrapper 脚本存在性
|
|
|
|
|
|
if (fs.existsSync(wrapperPath)) {
|
|
|
|
|
|
score += 20;
|
|
|
|
|
|
passes.push('wrapper');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('wrapper 缺失');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Proxy bootstrap 存在性
|
|
|
|
|
|
if (fs.existsSync(bootstrapPath)) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
passes.push('bootstrap');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('proxy-bootstrap 缺失');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. node_modules 安装状态
|
|
|
|
|
|
if (fs.existsSync(agentJs)) {
|
|
|
|
|
|
score += 15;
|
|
|
|
|
|
passes.push('npm-installed');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('node_modules 未安装');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. agent.js patch 状态 ([PATCHED] 标记)
|
|
|
|
|
|
if (fs.existsSync(agentJs)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const content = fs.readFileSync(agentJs, 'utf8');
|
|
|
|
|
|
if (content.includes('[PATCHED]')) {
|
|
|
|
|
|
score += 20;
|
|
|
|
|
|
passes.push('agent-patched');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('agent.js 未 patch (Gemini CUA 不可用)');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
issues.push('agent.js 读取失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Stagehand model map patch 状态
|
|
|
|
|
|
if (fs.existsSync(stagehandIndex)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const content = fs.readFileSync(stagehandIndex, 'utf8');
|
|
|
|
|
|
if (content.includes('claude-sonnet-4-6') || content.includes('claude-opus-4-6')) {
|
|
|
|
|
|
score += 15;
|
|
|
|
|
|
passes.push('model-map-patched');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('Stagehand model map 未 patch');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
issues.push('Stagehand index.js 读取失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 6. postinstall 脚本存在(npm install 后自动修复能力)
|
|
|
|
|
|
if (fs.existsSync(patchScript)) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
passes.push('postinstall');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('postinstall-patch.js 缺失 (npm install 后 patch 不会自动恢复)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 7. .claude.json 环境变量检查 (BROWSERBASE_API_KEY + PROJECT_ID)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const claudeJsonPath = path.join(process.env.USERPROFILE || process.env.HOME || '', '.claude.json');
|
|
|
|
|
|
if (fs.existsSync(claudeJsonPath)) {
|
|
|
|
|
|
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
|
|
|
|
const bbEnv = claudeJson?.mcpServers?.browserbase?.env || {};
|
|
|
|
|
|
if (bbEnv.BROWSERBASE_API_KEY && bbEnv.BROWSERBASE_PROJECT_ID) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
passes.push('env-keys');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
issues.push('API Key 或 Project ID 未配置');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 8. 用量查询 (可选, 网络不通时跳过)
|
|
|
|
|
|
let usageNote = '';
|
|
|
|
|
|
let sessionNote = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const claudeJsonPath = path.join(process.env.USERPROFILE || process.env.HOME || '', '.claude.json');
|
|
|
|
|
|
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
|
|
|
|
const bbEnv = claudeJson?.mcpServers?.browserbase?.env || {};
|
|
|
|
|
|
const apiKey = bbEnv.BROWSERBASE_API_KEY;
|
|
|
|
|
|
const projectId = bbEnv.BROWSERBASE_PROJECT_ID;
|
|
|
|
|
|
const proxy = bbEnv.https_proxy || bbEnv.http_proxy || '';
|
|
|
|
|
|
|
|
|
|
|
|
if (apiKey && projectId) {
|
|
|
|
|
|
const { execFileSync: execF } = require('child_process');
|
|
|
|
|
|
|
|
|
|
|
|
// 用量查询
|
|
|
|
|
|
const curlArgs = ['-s', '-m', '5', '-H', `x-bb-api-key: ${apiKey}`,
|
|
|
|
|
|
`https://api.browserbase.com/v1/projects/${projectId}/usage`];
|
|
|
|
|
|
if (proxy) curlArgs.unshift('-x', proxy);
|
|
|
|
|
|
const usageResult = execF('curl', curlArgs, {
|
|
|
|
|
|
encoding: 'utf8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
|
|
|
|
}).trim();
|
|
|
|
|
|
const usage = JSON.parse(usageResult);
|
|
|
|
|
|
const minutes = usage.browserMinutes || 0;
|
|
|
|
|
|
const proxyMB = Math.round((usage.proxyBytes || 0) / 1024 / 1024);
|
|
|
|
|
|
usageNote = `, 用量: ${minutes}min/${proxyMB}MB`;
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存用量到文件 (供周报使用)
|
|
|
|
|
|
const usageCacheFile = path.join(CLAUDE_ROOT, 'debug', 'browserbase-usage.json');
|
|
|
|
|
|
fs.writeFileSync(usageCacheFile, JSON.stringify({
|
|
|
|
|
|
ts: new Date().toISOString(), browserMinutes: minutes, proxyMB,
|
|
|
|
|
|
}, null, 2));
|
|
|
|
|
|
|
|
|
|
|
|
// Session 泄漏检测
|
|
|
|
|
|
const sessArgs = ['-s', '-m', '5', '-H', `x-bb-api-key: ${apiKey}`,
|
|
|
|
|
|
`https://api.browserbase.com/v1/sessions?status=RUNNING&limit=50`];
|
|
|
|
|
|
if (proxy) sessArgs.unshift('-x', proxy);
|
|
|
|
|
|
const sessResult = execF('curl', sessArgs, {
|
|
|
|
|
|
encoding: 'utf8', timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
|
|
|
|
}).trim();
|
|
|
|
|
|
const sessData = JSON.parse(sessResult);
|
|
|
|
|
|
const runningSessions = Array.isArray(sessData) ? sessData : (sessData?.sessions || []);
|
|
|
|
|
|
const staleCount = runningSessions.filter(s => {
|
|
|
|
|
|
const created = new Date(s.createdAt || s.created_at || 0).getTime();
|
2026-04-27 17:59:44 +08:00
|
|
|
|
return safeAge(created) > 30 * 60 * 1000;
|
2026-04-21 17:57:05 +08:00
|
|
|
|
}).length;
|
|
|
|
|
|
|
|
|
|
|
|
if (staleCount > 0) {
|
|
|
|
|
|
issues.push(`${staleCount} 个泄漏 session (>30min)`);
|
|
|
|
|
|
sessionNote = `, 泄漏: ${staleCount}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sessionNote = `, session: ${runningSessions.length} running`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存 session 状态
|
|
|
|
|
|
const sessCacheFile = path.join(CLAUDE_ROOT, 'debug', 'browserbase-sessions.json');
|
|
|
|
|
|
fs.writeFileSync(sessCacheFile, JSON.stringify({
|
|
|
|
|
|
ts: new Date().toISOString(),
|
|
|
|
|
|
running: runningSessions.length,
|
|
|
|
|
|
stale: staleCount,
|
|
|
|
|
|
}, null, 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 网络不通或 API 异常时静默跳过,不影响评分
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 评分与状态
|
|
|
|
|
|
score = Math.min(100, score);
|
|
|
|
|
|
const detail = issues.length > 0
|
|
|
|
|
|
? `${passes.length}/7 通过, 问题: ${issues.join('; ')}${usageNote}${sessionNote}`
|
|
|
|
|
|
: `${passes.length}/7 全部通过${usageNote}${sessionNote}`;
|
|
|
|
|
|
const status = score >= 90 ? 'PASS' : score >= 60 ? 'WARN' : 'FAIL';
|
|
|
|
|
|
addDimension('H12', 'Browserbase', score, status, detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === v5.1: 权重自优化 ===
|
|
|
|
|
|
const WEIGHT_HISTORY_FILE = path.join(CLAUDE_ROOT, 'debug', 'health-weight-history.json');
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 基于瓶颈维度频率自动调整 H1-H8 权重
|
|
|
|
|
|
* @param {Object} currentWeights - 当前权重
|
|
|
|
|
|
* @returns {Object} 调整后的权重
|
|
|
|
|
|
*/
|
|
|
|
|
|
function autoTuneWeights(currentWeights) {
|
|
|
|
|
|
let history = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(WEIGHT_HISTORY_FILE)) {
|
|
|
|
|
|
history = JSON.parse(fs.readFileSync(WEIGHT_HISTORY_FILE, 'utf8'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 记录当前评分到历史
|
|
|
|
|
|
const currentRecord = {
|
|
|
|
|
|
ts: new Date().toISOString(),
|
|
|
|
|
|
dimensions: dimensions.map(d => ({ id: d.id, score: d.score, status: d.status })),
|
|
|
|
|
|
};
|
|
|
|
|
|
history.push(currentRecord);
|
|
|
|
|
|
|
|
|
|
|
|
// 仅保留最近 30 条
|
|
|
|
|
|
if (history.length > 30) history = history.slice(-30);
|
|
|
|
|
|
|
|
|
|
|
|
// 保存历史
|
|
|
|
|
|
try {
|
|
|
|
|
|
const dir = path.dirname(WEIGHT_HISTORY_FILE);
|
|
|
|
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
|
fs.writeFileSync(WEIGHT_HISTORY_FILE, JSON.stringify(history, null, 2) + '\n');
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 需要至少 5 条历史才做调整
|
|
|
|
|
|
if (history.length < 5) return currentWeights;
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1+2: 差异化阈值 — 不同维度组使用不同的瓶颈判定标准
|
|
|
|
|
|
const BOTTLENECK_THRESHOLDS = {
|
|
|
|
|
|
H1: 85, H4: 85, H6: 85, // 配置类:应更灵敏
|
|
|
|
|
|
H7: 75, H9: 75, // 准确率类:波动正常
|
|
|
|
|
|
H3: 80, // 磁盘类:宽松
|
|
|
|
|
|
H2: 70, H5: 70, H8: 70, // 其余:保持默认
|
|
|
|
|
|
H10: 70, // Hook有效性:保持默认
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: 告警抑制 — 24 小时内同维度低分不重复告警
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const suppressWindow = 24 * 60 * 60 * 1000; // 24h in ms
|
|
|
|
|
|
const recentAlerts = {};
|
|
|
|
|
|
for (const record of history) {
|
|
|
|
|
|
const recordTs = new Date(record.ts);
|
|
|
|
|
|
if (now - recordTs < suppressWindow) {
|
|
|
|
|
|
for (const d of (record.dimensions || [])) {
|
|
|
|
|
|
const threshold = BOTTLENECK_THRESHOLDS[d.id] || 70;
|
|
|
|
|
|
if (d.score < threshold) {
|
|
|
|
|
|
recentAlerts[d.id] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 统计各维度低分频率 (使用差异化阈值)
|
|
|
|
|
|
const bottleneckFreq = {};
|
|
|
|
|
|
for (const record of history) {
|
|
|
|
|
|
for (const d of (record.dimensions || [])) {
|
|
|
|
|
|
const threshold = BOTTLENECK_THRESHOLDS[d.id] || 70;
|
|
|
|
|
|
if (d.score < threshold) {
|
|
|
|
|
|
bottleneckFreq[d.id] = (bottleneckFreq[d.id] || 0) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 频繁瓶颈维度提升权重, 稳定维度降低权重
|
|
|
|
|
|
const adjusted = { ...currentWeights };
|
|
|
|
|
|
const totalRecords = history.length;
|
|
|
|
|
|
|
|
|
|
|
|
for (const id of Object.keys(adjusted)) {
|
|
|
|
|
|
const freq = (bottleneckFreq[id] || 0) / totalRecords;
|
|
|
|
|
|
if (freq > 0.5) {
|
|
|
|
|
|
// 经常是瓶颈: 提升权重 (+3)
|
|
|
|
|
|
adjusted[id] = Math.min(25, adjusted[id] + 3);
|
|
|
|
|
|
} else if (freq === 0) {
|
|
|
|
|
|
// 从不是瓶颈: 降低权重 (-2)
|
|
|
|
|
|
adjusted[id] = Math.max(5, adjusted[id] - 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: 计算被抑制的告警
|
|
|
|
|
|
const alertsSuppressed = [];
|
|
|
|
|
|
for (const d of dimensions) {
|
|
|
|
|
|
const threshold = BOTTLENECK_THRESHOLDS[d.id] || 70;
|
|
|
|
|
|
if (d.score < threshold && recentAlerts[d.id]) {
|
|
|
|
|
|
alertsSuppressed.push(d.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
adjusted._alertsSuppressed = alertsSuppressed;
|
|
|
|
|
|
return adjusted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === Phase 1: 告警分级 ===
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据偏离程度分级告警
|
|
|
|
|
|
* @param {Object} dim - 维度 { id, score, status }
|
|
|
|
|
|
* @param {number} threshold - 该维度的瓶颈阈值
|
|
|
|
|
|
* @returns {'NORMAL'|'ALERT'|'AUTO-REPAIR'}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function classifyAlert(dim, threshold) {
|
|
|
|
|
|
if (dim.score >= threshold) return 'NORMAL';
|
|
|
|
|
|
const deviation = threshold - dim.score;
|
|
|
|
|
|
const deviationPct = deviation / threshold;
|
|
|
|
|
|
if (deviationPct > 0.10) return 'AUTO-REPAIR'; // >10% 偏离
|
|
|
|
|
|
return 'ALERT'; // 5-10% 偏离
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === Phase 3: 自动修复引擎 (D4) ===
|
|
|
|
|
|
const REMEDIATION_MAP = {
|
|
|
|
|
|
H1: { label: '配置验证修复', script: 'scripts/config-validator.js', args: '--fix' },
|
|
|
|
|
|
H3: { label: '磁盘清理', script: 'scripts/auto-cleanup.js', args: '--execute' },
|
|
|
|
|
|
H4: { label: '完整性修复', script: 'hooks/integrity-check.js', args: '--generate' },
|
|
|
|
|
|
H5: { label: '技能索引重建', script: 'scripts/generate-skill-index.js', args: '--quiet' },
|
|
|
|
|
|
H6: { label: '规则缓存重建', script: 'scripts/compile-rules.js', args: '' },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const REMEDIATION_LOG_FILE = path.join(CLAUDE_ROOT, 'debug', 'remediation-log.jsonl');
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 记录修复操作
|
|
|
|
|
|
* @param {Object} entry - 修复记录
|
|
|
|
|
|
*/
|
|
|
|
|
|
function logRemediation(entry) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const debugDir = path.join(CLAUDE_ROOT, 'debug');
|
|
|
|
|
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
|
|
|
|
|
fs.appendFileSync(REMEDIATION_LOG_FILE, JSON.stringify(entry) + '\n');
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 修复后重跑该维度检查函数,确认分数提升
|
|
|
|
|
|
* @param {string} dimensionId - 维度 ID (H3|H4|H6)
|
|
|
|
|
|
* @returns {{ improved: boolean, newScore: number }|null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function verifyRemediation(dimensionId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const checkFns = { H3: checkDisk, H4: checkIntegrity, H6: checkRulesCache };
|
|
|
|
|
|
const fn = checkFns[dimensionId];
|
|
|
|
|
|
if (!fn) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 保存当前 dimensions,运行单维度检查
|
|
|
|
|
|
const savedDimensions = [...dimensions];
|
|
|
|
|
|
const idx = dimensions.findIndex(d => d.id === dimensionId);
|
|
|
|
|
|
const oldScore = idx >= 0 ? dimensions[idx].score : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 移除旧维度,重新检查
|
|
|
|
|
|
if (idx >= 0) dimensions.splice(idx, 1);
|
|
|
|
|
|
fn();
|
|
|
|
|
|
const newDim = dimensions.find(d => d.id === dimensionId);
|
|
|
|
|
|
const newScore = newDim ? newDim.score : oldScore;
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复 (保留新检查结果)
|
|
|
|
|
|
return { improved: newScore > oldScore, newScore };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 对 AUTO-REPAIR 级别维度执行自动修复
|
|
|
|
|
|
* @param {Array} gradedDimensions - 带 alertLevel 的维度列表
|
|
|
|
|
|
* @returns {Array} 修复结果数组
|
|
|
|
|
|
*/
|
|
|
|
|
|
function autoRemediate(gradedDimensions) {
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
for (const d of gradedDimensions) {
|
|
|
|
|
|
if (d.alertLevel !== 'AUTO-REPAIR' || d.suppressed) continue;
|
|
|
|
|
|
const remedy = REMEDIATION_MAP[d.id];
|
|
|
|
|
|
if (!remedy) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const entry = {
|
|
|
|
|
|
ts: new Date().toISOString(),
|
|
|
|
|
|
dimensionId: d.id,
|
|
|
|
|
|
dimensionName: d.name,
|
|
|
|
|
|
action: remedy.label,
|
|
|
|
|
|
script: remedy.script,
|
|
|
|
|
|
scoreBefore: d.score,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const scriptResult = runScript(remedy.script, remedy.args);
|
|
|
|
|
|
entry.success = scriptResult.ok;
|
|
|
|
|
|
entry.output = (scriptResult.output || '').slice(0, 200);
|
|
|
|
|
|
if (!scriptResult.ok) entry.error = (scriptResult.error || '').slice(0, 200);
|
|
|
|
|
|
|
|
|
|
|
|
// 验证修复效果
|
|
|
|
|
|
if (scriptResult.ok) {
|
|
|
|
|
|
const verification = verifyRemediation(d.id);
|
|
|
|
|
|
if (verification) {
|
|
|
|
|
|
entry.scoreAfter = verification.newScore;
|
|
|
|
|
|
entry.improved = verification.improved;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
entry.success = false;
|
|
|
|
|
|
entry.error = (e.message || '').slice(0, 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logRemediation(entry);
|
|
|
|
|
|
results.push(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 主流程 ===
|
|
|
|
|
|
function main() {
|
|
|
|
|
|
checkConfig();
|
|
|
|
|
|
checkBehavior();
|
|
|
|
|
|
checkDisk();
|
|
|
|
|
|
checkIntegrity();
|
|
|
|
|
|
checkSkillIndex();
|
|
|
|
|
|
checkRulesCache();
|
|
|
|
|
|
checkRouteAccuracy();
|
|
|
|
|
|
checkLearningConvergence();
|
|
|
|
|
|
checkRouteCompliance();
|
|
|
|
|
|
checkHookEffectiveness();
|
|
|
|
|
|
checkConstitutionCoverage();
|
|
|
|
|
|
checkBrowserbaseMcp();
|
|
|
|
|
|
|
|
|
|
|
|
// v6.5: 默认权重 + 自优化 (H12 Browserbase 加入)
|
|
|
|
|
|
let weights = { H1: 10, H2: 10, H3: 8, H4: 10, H5: 8, H6: 8, H7: 10, H8: 8, H9: 8, H10: 8, H11: 7, H12: 5 };
|
|
|
|
|
|
weights = autoTuneWeights(weights);
|
|
|
|
|
|
|
|
|
|
|
|
let totalWeight = 0, weightedSum = 0;
|
|
|
|
|
|
for (const d of dimensions) {
|
|
|
|
|
|
const w = weights[d.id] || 10;
|
|
|
|
|
|
totalWeight += w;
|
|
|
|
|
|
weightedSum += d.score * w;
|
|
|
|
|
|
}
|
|
|
|
|
|
const overallScore = Math.round(weightedSum / totalWeight);
|
|
|
|
|
|
|
|
|
|
|
|
let overallStatus = 'HEALTHY';
|
|
|
|
|
|
if (overallScore < 60) overallStatus = 'CRITICAL';
|
|
|
|
|
|
else if (overallScore < 80) overallStatus = 'DEGRADED';
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: 告警分级与抑制
|
|
|
|
|
|
const alertsSuppressed = weights._alertsSuppressed || [];
|
|
|
|
|
|
delete weights._alertsSuppressed;
|
|
|
|
|
|
|
|
|
|
|
|
const BOTTLENECK_THRESHOLDS = {
|
|
|
|
|
|
H1: 85, H4: 85, H6: 85,
|
|
|
|
|
|
H7: 75, H9: 75,
|
|
|
|
|
|
H3: 80,
|
|
|
|
|
|
H2: 70, H5: 70, H8: 70,
|
|
|
|
|
|
H10: 70,
|
|
|
|
|
|
H12: 70,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 为每个维度附加告警级别
|
|
|
|
|
|
const gradedDimensions = dimensions.map(d => {
|
|
|
|
|
|
const threshold = BOTTLENECK_THRESHOLDS[d.id] || 70;
|
|
|
|
|
|
const alertLevel = classifyAlert(d, threshold);
|
|
|
|
|
|
const suppressed = alertsSuppressed.includes(d.id);
|
|
|
|
|
|
return { ...d, alertLevel, suppressed };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 3: 自动修复 (D4)
|
|
|
|
|
|
const remediations = autoRemediate(gradedDimensions);
|
|
|
|
|
|
|
|
|
|
|
|
const report = {
|
|
|
|
|
|
ts: new Date().toISOString(),
|
|
|
|
|
|
overallScore,
|
|
|
|
|
|
overallStatus,
|
|
|
|
|
|
dimensions: gradedDimensions,
|
|
|
|
|
|
alertsSuppressed,
|
|
|
|
|
|
remediations,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (JSON_MODE) {
|
|
|
|
|
|
console.log(JSON.stringify(report, null, 2));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 文本输出
|
|
|
|
|
|
const statusIcon = { PASS: '+', WARN: '~', FAIL: 'X', INFO: 'i' };
|
|
|
|
|
|
console.log('=== Bookworm Health Check ===');
|
|
|
|
|
|
console.log(`Overall: ${overallScore}/100 (${overallStatus})`);
|
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
|
|
for (const d of gradedDimensions) {
|
|
|
|
|
|
const icon = statusIcon[d.status] || '?';
|
|
|
|
|
|
const alertTag = d.alertLevel !== 'NORMAL' ? ` [${d.alertLevel}]` : '';
|
|
|
|
|
|
const suppressTag = d.suppressed ? ' (suppressed)' : '';
|
|
|
|
|
|
console.log(` [${icon}] ${d.id} ${d.name.padEnd(12)} ${String(d.score).padStart(3)}/100 ${d.detail}${alertTag}${suppressTag}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (alertsSuppressed.length > 0) {
|
|
|
|
|
|
console.log(`\n (${alertsSuppressed.length} alert(s) suppressed — same dimension alerted within 24h)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 3: Auto-Remediation 输出
|
|
|
|
|
|
if (remediations.length > 0) {
|
|
|
|
|
|
console.log('\n--- Auto-Remediation ---');
|
|
|
|
|
|
for (const r of remediations) {
|
|
|
|
|
|
const icon = r.success ? '+' : 'X';
|
|
|
|
|
|
const scoreChange = r.scoreAfter !== undefined ? ` (${r.scoreBefore} → ${r.scoreAfter})` : '';
|
|
|
|
|
|
console.log(` [${icon}] ${r.dimensionId} ${r.action}${scoreChange}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('');
|
|
|
|
|
|
if (overallStatus === 'HEALTHY') {
|
|
|
|
|
|
console.log('System is healthy. No action required.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const issues = gradedDimensions.filter(d => d.alertLevel === 'AUTO-REPAIR' && !d.suppressed);
|
|
|
|
|
|
const alerts = gradedDimensions.filter(d => d.alertLevel === 'ALERT' && !d.suppressed);
|
|
|
|
|
|
if (issues.length > 0) {
|
|
|
|
|
|
console.log(`Action needed: ${issues.length} dimension(s) require auto-repair.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (alerts.length > 0) {
|
|
|
|
|
|
console.log(`Alert: ${alerts.length} dimension(s) slightly below threshold (monitor only).`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (issues.length === 0 && alerts.length === 0) {
|
|
|
|
|
|
console.log('All alerts suppressed (already reported within 24h).');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 导出核心函数供测试使用
|
|
|
|
|
|
if (typeof module !== 'undefined') {
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
addDimension, checkConfig, checkBehavior, checkDisk, checkIntegrity,
|
|
|
|
|
|
checkSkillIndex, checkRulesCache, checkRouteAccuracy, checkLearningConvergence,
|
|
|
|
|
|
checkRouteCompliance, checkHookEffectiveness, checkConstitutionCoverage,
|
|
|
|
|
|
checkBrowserbaseMcp, autoTuneWeights, classifyAlert,
|
|
|
|
|
|
autoRemediate, logRemediation, verifyRemediation,
|
|
|
|
|
|
get dimensions() { return dimensions; }, resetDimensions,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (require.main === module) {
|
|
|
|
|
|
main();
|
|
|
|
|
|
}
|