2026-04-21 17:57:05 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 系统统计自动生成器
|
|
|
|
|
|
*
|
|
|
|
|
|
* 扫描 hooks/skills/agents/settings.json,生成 stats-compiled.json
|
|
|
|
|
|
* 消除手动声明计数,作为唯一真相源
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用法:
|
|
|
|
|
|
* node scripts/generate-stats.js # 生成 stats-compiled.json
|
|
|
|
|
|
* node scripts/generate-stats.js --json # 输出 JSON 到 stdout (供其他脚本消费)
|
|
|
|
|
|
* node scripts/generate-stats.js --quiet # 静默模式 (供 hook 调用)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 输出文件: {root}/stats-compiled.json
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
|
|
|
|
|
|
const detectRoot = () => require('./paths.config.js').PATHS.root;
|
|
|
|
|
|
|
|
|
|
|
|
const ROOT = detectRoot();
|
|
|
|
|
|
|
|
|
|
|
|
// === 扫描钩子 ===
|
|
|
|
|
|
function scanHooks() {
|
|
|
|
|
|
const hooksDir = path.join(ROOT, 'hooks');
|
|
|
|
|
|
const allFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.js'));
|
|
|
|
|
|
|
|
|
|
|
|
// 读取 settings.json 中已注册的钩子脚本
|
|
|
|
|
|
let registeredScripts = new Set();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, 'settings.json'), 'utf8'));
|
|
|
|
|
|
const hooks = settings.hooks || {};
|
|
|
|
|
|
for (const [, entries] of Object.entries(hooks)) {
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
for (const hook of (entry.hooks || [])) {
|
|
|
|
|
|
if (hook.command) {
|
|
|
|
|
|
// 修复: 从命令中提取 .js 文件名 (处理重定向/|| true 后缀)
|
|
|
|
|
|
const jsMatch = hook.command.match(/[\w.\/\\-]+\.js/);
|
|
|
|
|
|
if (jsMatch) {
|
|
|
|
|
|
registeredScripts.add(path.basename(jsMatch[0]));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* settings.json 不存在时跳过 */ }
|
|
|
|
|
|
|
|
|
|
|
|
// 分类: 按 hook 类型 (从注释头或文件名推断)
|
|
|
|
|
|
const PRE_PATTERNS = ['block-sensitive', 'block-dangerous', 'commit-message-lint', 'route-compliance'];
|
|
|
|
|
|
const ROUTE_PATTERNS = ['route-interceptor', 'subagent-route-injector', 'route-auditor'];
|
|
|
|
|
|
|
|
|
|
|
|
let preCount = 0, postCount = 0, routeCount = 0;
|
|
|
|
|
|
const registered = [];
|
|
|
|
|
|
const unregistered = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const file of allFiles) {
|
|
|
|
|
|
// 排除测试目录中的文件
|
|
|
|
|
|
if (file.startsWith('__')) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const isRegistered = registeredScripts.has(file);
|
|
|
|
|
|
const info = { file, registered: isRegistered };
|
|
|
|
|
|
|
|
|
|
|
|
if (ROUTE_PATTERNS.some(p => file.includes(p))) {
|
|
|
|
|
|
info.type = 'route';
|
|
|
|
|
|
routeCount++;
|
|
|
|
|
|
} else if (PRE_PATTERNS.some(p => file.includes(p))) {
|
|
|
|
|
|
info.type = 'pre';
|
|
|
|
|
|
preCount++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
info.type = 'post';
|
|
|
|
|
|
postCount++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isRegistered) {
|
|
|
|
|
|
registered.push(info);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unregistered.push(info);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
total: allFiles.length,
|
|
|
|
|
|
registered: registered.length,
|
|
|
|
|
|
unregistered: unregistered.length,
|
|
|
|
|
|
breakdown: { pre: preCount, post: postCount, route: routeCount },
|
|
|
|
|
|
unregisteredFiles: unregistered.map(u => u.file),
|
|
|
|
|
|
files: allFiles,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 扫描技能 ===
|
|
|
|
|
|
function scanSkills() {
|
|
|
|
|
|
const skillsDir = path.join(ROOT, 'skills');
|
|
|
|
|
|
if (!fs.existsSync(skillsDir)) return { total: 0, stable: 0, beta: 0, composable: 0, dirs: [] };
|
|
|
|
|
|
|
|
|
|
|
|
const dirs = fs.readdirSync(skillsDir).filter(d =>
|
|
|
|
|
|
fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let stable = 0, beta = 0, composable = 0;
|
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
|
const content = fs.readFileSync(path.join(skillsDir, dir, 'SKILL.md'), 'utf8');
|
|
|
|
|
|
if (/maturity:\s*stable/.test(content)) stable++;
|
|
|
|
|
|
else if (/maturity:\s*beta/.test(content)) beta++;
|
|
|
|
|
|
if (/composable:/.test(content)) composable++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { total: dirs.length, stable, beta, composable, dirs };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 扫描智能体 ===
|
|
|
|
|
|
function scanAgents() {
|
|
|
|
|
|
const agentsDir = path.join(ROOT, 'agents');
|
|
|
|
|
|
if (!fs.existsSync(agentsDir)) return { total: 0, opus: 0, sonnet: 0, files: [] };
|
|
|
|
|
|
|
|
|
|
|
|
const files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
|
|
|
|
|
|
let opus = 0, sonnet = 0, haiku = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
const content = fs.readFileSync(path.join(agentsDir, file), 'utf8');
|
|
|
|
|
|
if (/model:\s*opus/i.test(content)) opus++;
|
|
|
|
|
|
else if (/model:\s*sonnet/i.test(content)) sonnet++;
|
|
|
|
|
|
else if (/model:\s*haiku/i.test(content)) haiku++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { total: files.length, opus, sonnet, haiku, files };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 云托管 MCP 和插件 (非本地配置,手动维护) ===
|
|
|
|
|
|
const CLOUD_MCP = ['sentry', 'notion', 'gamma', 'canva', 'vercel', 'cloudinary', 'scholar-gateway', 'graphos'];
|
|
|
|
|
|
const PLUGIN_MCP = ['firebase'];
|
|
|
|
|
|
|
|
|
|
|
|
// === 扫描 MCP ===
|
|
|
|
|
|
function scanMCP() {
|
|
|
|
|
|
let localServers = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
// MCP 服务器配置在 ~/.claude.json (非 ~/.claude/settings.json)
|
|
|
|
|
|
const claudeJsonPath = path.join(path.dirname(ROOT), '.claude.json');
|
|
|
|
|
|
const config = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
|
|
|
|
const servers = config.mcpServers || {};
|
|
|
|
|
|
localServers = Object.keys(servers);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 回退: 尝试 settings.json
|
|
|
|
|
|
try {
|
|
|
|
|
|
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, 'settings.json'), 'utf8'));
|
|
|
|
|
|
const servers = settings.mcpServers || {};
|
|
|
|
|
|
localServers = Object.keys(servers);
|
|
|
|
|
|
} catch { /* 无配置 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
local: localServers.length,
|
|
|
|
|
|
cloud: CLOUD_MCP.length,
|
|
|
|
|
|
plugin: PLUGIN_MCP.length,
|
|
|
|
|
|
total: localServers.length + CLOUD_MCP.length + PLUGIN_MCP.length,
|
|
|
|
|
|
servers: localServers,
|
|
|
|
|
|
cloudServers: CLOUD_MCP,
|
|
|
|
|
|
pluginServers: PLUGIN_MCP,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 扫描脚本 ===
|
|
|
|
|
|
function scanScripts() {
|
|
|
|
|
|
const scriptsDir = path.join(ROOT, 'scripts');
|
|
|
|
|
|
if (!fs.existsSync(scriptsDir)) return { total: 0, files: [] };
|
|
|
|
|
|
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.js'));
|
|
|
|
|
|
return { total: files.length, files };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 读取技能索引 ===
|
|
|
|
|
|
function readSkillsIndex() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills-index.json'), 'utf8'));
|
|
|
|
|
|
return {
|
|
|
|
|
|
skillCount: index.skillCount || 0,
|
|
|
|
|
|
totalKeywords: (index.skills || []).reduce((sum, s) => sum + (s.keywords || []).length, 0),
|
|
|
|
|
|
version: index.version || 'unknown',
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { skillCount: 0, totalKeywords: 0, version: 'unknown' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 主流程 ===
|
2026-04-27 17:59:44 +08:00
|
|
|
|
|
|
|
|
|
|
// === PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24 ===
|
|
|
|
|
|
// Phase 1 · T1.3: 读取 MCP 使用率 + 每日健康快照
|
|
|
|
|
|
function scanMcpObservability() {
|
|
|
|
|
|
const obs = { mcpUtilization: null, mcpHealth: null };
|
|
|
|
|
|
const usageFile = path.join(ROOT, 'mcp-usage-week.json');
|
|
|
|
|
|
const logsDir = path.join(ROOT, 'logs');
|
|
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
const healthFile = path.join(logsDir, 'mcp-health-' + today + '.json');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(usageFile)) {
|
|
|
|
|
|
const u = JSON.parse(fs.readFileSync(usageFile, 'utf8'));
|
|
|
|
|
|
obs.mcpUtilization = {
|
|
|
|
|
|
schema_version: u.schema_version || 1,
|
|
|
|
|
|
generated: u.generated,
|
|
|
|
|
|
windowDays: u.windowDays,
|
|
|
|
|
|
totalEvents: u.totalEvents,
|
|
|
|
|
|
activeCount: Object.values(u.mcpStats || {}).filter(s => s.totalCalls > 0).length,
|
|
|
|
|
|
pruneCandidateCount: (u.pruneCandidates || []).length,
|
|
|
|
|
|
pruneCandidates: (u.pruneCandidates || []).map(p => p.server),
|
|
|
|
|
|
criticalCount: (u.criticalSet || []).length
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(healthFile)) {
|
|
|
|
|
|
const h = JSON.parse(fs.readFileSync(healthFile, 'utf8'));
|
|
|
|
|
|
obs.mcpHealth = {
|
|
|
|
|
|
schema_version: h.schema_version || 1,
|
|
|
|
|
|
date: h.date,
|
|
|
|
|
|
probedAt: h.probedAt,
|
|
|
|
|
|
probeKind: h.probeKind,
|
|
|
|
|
|
totalMcps: h.totalMcps,
|
|
|
|
|
|
reachable: h.reachable,
|
|
|
|
|
|
unreachable: h.unreachable,
|
|
|
|
|
|
unreachableList: h.unreachableList
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
return obs;
|
|
|
|
|
|
}
|
|
|
|
|
|
// === END PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24 ===
|
|
|
|
|
|
|
2026-04-21 17:57:05 +08:00
|
|
|
|
function generateStats() {
|
|
|
|
|
|
const hooks = scanHooks();
|
|
|
|
|
|
const skills = scanSkills();
|
|
|
|
|
|
const agents = scanAgents();
|
|
|
|
|
|
const mcp = scanMCP();
|
|
|
|
|
|
const scripts = scanScripts();
|
2026-04-27 17:59:44 +08:00
|
|
|
|
const skillsIndex = readSkillsIndex();
|
|
|
|
|
|
const mcpObs = scanMcpObservability(); // T1.3
|
2026-04-21 17:57:05 +08:00
|
|
|
|
|
|
|
|
|
|
const stats = {
|
|
|
|
|
|
generated: new Date().toISOString(),
|
|
|
|
|
|
version: (function() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var claudeMd = require('fs').readFileSync(require('path').join(ROOT, 'CLAUDE.md'), 'utf8');
|
|
|
|
|
|
var m = claudeMd.match(/Smart Assistant[^v]*(v[\d.]+)/);
|
|
|
|
|
|
return m ? m[1] : 'v6.5';
|
|
|
|
|
|
} catch { return 'v6.5'; }
|
|
|
|
|
|
})(),
|
|
|
|
|
|
|
|
|
|
|
|
// 核心计数 (唯一真相源)
|
|
|
|
|
|
summary: {
|
|
|
|
|
|
skills: skills.total,
|
|
|
|
|
|
skillsStable: skills.stable,
|
|
|
|
|
|
skillsBeta: skills.beta,
|
|
|
|
|
|
skillsComposable: skills.composable,
|
|
|
|
|
|
agents: agents.total,
|
|
|
|
|
|
agentsOpus: agents.opus,
|
|
|
|
|
|
agentsSonnet: agents.sonnet,
|
|
|
|
|
|
agentsHaiku: agents.haiku,
|
|
|
|
|
|
hooks: hooks.total,
|
|
|
|
|
|
hooksRegistered: hooks.registered,
|
|
|
|
|
|
hooksUnregistered: hooks.unregistered,
|
|
|
|
|
|
hooksPre: hooks.breakdown.pre,
|
|
|
|
|
|
hooksPost: hooks.breakdown.post,
|
|
|
|
|
|
hooksRoute: hooks.breakdown.route,
|
|
|
|
|
|
mcp: mcp.local,
|
|
|
|
|
|
mcpCloud: mcp.cloud,
|
|
|
|
|
|
mcpPlugin: mcp.plugin,
|
|
|
|
|
|
mcpTotal: mcp.total,
|
|
|
|
|
|
scripts: scripts.total,
|
|
|
|
|
|
indexKeywords: skillsIndex.totalKeywords,
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 一致性校验
|
|
|
|
|
|
consistency: {
|
|
|
|
|
|
skillsMatch: skills.total === skillsIndex.skillCount,
|
|
|
|
|
|
skillsDir: skills.total,
|
|
|
|
|
|
skillsIndex: skillsIndex.skillCount,
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 未注册钩子清单 (供审计引用)
|
|
|
|
|
|
unregisteredHooks: hooks.unregisteredFiles,
|
|
|
|
|
|
|
|
|
|
|
|
// 设计决策: 审计时识别为有意选择,不扣分
|
|
|
|
|
|
designDecisions: {
|
|
|
|
|
|
unregisteredHooksIntentional: true,
|
|
|
|
|
|
reason: '备用钩子 (check-lint/check-typescript/integrity-check/suggest-tests) 设计为按需激活,不默认注册以避免每次文件操作额外延迟',
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-27 17:59:44 +08:00
|
|
|
|
// Phase 1 · T1.3: MCP 观测字段 (PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24)
|
|
|
|
|
|
|
|
|
|
|
|
mcpUtilization: mcpObs.mcpUtilization,
|
|
|
|
|
|
|
|
|
|
|
|
mcpHealth: mcpObs.mcpHealth,
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 17:57:05 +08:00
|
|
|
|
// 详细列表
|
|
|
|
|
|
details: {
|
|
|
|
|
|
hooks: hooks.files,
|
|
|
|
|
|
agents: agents.files,
|
|
|
|
|
|
mcp: mcp.servers,
|
|
|
|
|
|
mcpCloud: mcp.cloudServers,
|
|
|
|
|
|
mcpPlugin: mcp.pluginServers,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return stats;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function main() {
|
|
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
|
|
const jsonMode = args.includes('--json');
|
|
|
|
|
|
const quietMode = args.includes('--quiet');
|
|
|
|
|
|
|
|
|
|
|
|
const stats = generateStats();
|
|
|
|
|
|
|
|
|
|
|
|
// P2-V12: 原子写入 (temp+rename) 防止半写损坏
|
|
|
|
|
|
const outputPath = path.join(ROOT, 'stats-compiled.json');
|
|
|
|
|
|
const tmpPath = outputPath + '.tmp.' + process.pid;
|
|
|
|
|
|
fs.writeFileSync(tmpPath, JSON.stringify(stats, null, 2) + '\n');
|
|
|
|
|
|
fs.renameSync(tmpPath, outputPath);
|
|
|
|
|
|
|
|
|
|
|
|
if (jsonMode) {
|
|
|
|
|
|
process.stdout.write(JSON.stringify(stats));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!quietMode) {
|
|
|
|
|
|
const s = stats.summary;
|
|
|
|
|
|
console.log('stats-compiled.json generated:');
|
|
|
|
|
|
console.log(` Skills: ${s.skills} (${s.skillsStable} stable, ${s.skillsBeta} beta, ${s.skillsComposable} composable)`);
|
|
|
|
|
|
console.log(` Agents: ${s.agents} (${s.agentsOpus} opus, ${s.agentsSonnet} sonnet, ${s.agentsHaiku} haiku)`);
|
|
|
|
|
|
console.log(` Hooks: ${s.hooks} (${s.hooksPre} pre + ${s.hooksPost} post + ${s.hooksRoute} route, ${s.hooksRegistered} registered, ${s.hooksUnregistered} unregistered)`);
|
|
|
|
|
|
console.log(` MCP: ${s.mcpTotal} (${s.mcp} local + ${s.mcpCloud} cloud + ${s.mcpPlugin} plugin)`);
|
|
|
|
|
|
console.log(` Scripts: ${s.scripts}`);
|
|
|
|
|
|
console.log(` Index: ${s.indexKeywords} keywords`);
|
|
|
|
|
|
if (!stats.consistency.skillsMatch) {
|
|
|
|
|
|
console.log(` [WARN] Skills mismatch: dir=${stats.consistency.skillsDir} vs index=${stats.consistency.skillsIndex}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(` Output: ${outputPath}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 导出供测试和其他脚本使用
|
|
|
|
|
|
if (typeof module !== 'undefined') {
|
|
|
|
|
|
module.exports = { generateStats, scanHooks, scanSkills, scanAgents, scanMCP, scanScripts };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (require.main === module) {
|
|
|
|
|
|
main();
|
|
|
|
|
|
}
|