bookworm-smart-assistant/hooks/route-auditor.js

470 lines
16 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
/**
* Stop Hook: 端到端审计 + 反馈闭环 (v5.2 Neural Gateway)
*
* 会话结束时:
* 1. 读取 route-state-current.json
* 2. 读取当天 compliance 日志
* 3. 判定合规状态 (compliant / violated / skipped)
* 4. 写入审计记录
* 5. 累计违规 ≥ 5/天 → 触发 autoLearn
* 6. 清理 route-state-current.json
*
* stdin: { session_id, transcript_path, hook_event_name: "Stop" }
* 退出码: 始终 0
*/
const fs = require('fs');
const path = require('path');
const readStdin = require('./lib/read-stdin.js');
const CLAUDE_ROOT = require('./lib/root.js');
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
const SCRIPTS_DIR = path.join(CLAUDE_ROOT, 'scripts');
const STATE_FILE = path.join(DEBUG_DIR, 'route-state-current.json');
/**
* 加载路由状态
*/
function loadRouteState() {
try {
if (!fs.existsSync(STATE_FILE)) return null;
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch {
return null;
}
}
/**
* 读取当天 compliance 日志
* @returns {Object[]}
*/
function loadTodayCompliance() {
const dateStr = new Date().toISOString().slice(0, 10);
const logFile = path.join(DEBUG_DIR, `compliance-${dateStr}.jsonl`);
try {
if (!fs.existsSync(logFile)) return [];
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n');
return lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
} catch {
return [];
}
}
/**
* 查找当前 traceId 的 compliance 条目
*/
function findTraceEntries(entries, traceId) {
return entries.filter(e => e.traceId === traceId);
}
/**
* 查找当天 activity 日志中的 Skill 调用
*/
function findSkillUsage() {
const dateStr = new Date().toISOString().slice(0, 10);
const activityFile = path.join(DEBUG_DIR, `activity-${dateStr}.jsonl`);
try {
if (!fs.existsSync(activityFile)) return [];
const lines = fs.readFileSync(activityFile, 'utf8').trim().split('\n');
return lines
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(e => e && e.event === 'skill');
} catch {
return [];
}
}
/**
* 判定合规状态
* 数据源优先级: gate-pass 事件 > activity 日志 skill 事件
*/
function judgeCompliance(state, traceEntries, skillUsages) {
// 检查是否有 gate-block 事件
const gateBlocked = traceEntries.some(e => e.event === 'gate-block');
// 优先从 gate-pass 事件获取 actualSkill (合规门控已记录)
const gatePassed = traceEntries.filter(e => e.event === 'gate-pass');
let actualSkill = null;
if (gatePassed.length > 0) {
// 取最后一个 gate-pass 的 skill
actualSkill = gatePassed[gatePassed.length - 1].skill;
}
// T01: 从 route-state-current.json 的 actualSkill 字段读取 (compliance-gate 写入)
if (!actualSkill && state.actualSkill) {
actualSkill = state.actualSkill;
}
// H1 修复: 读 jsonl 按 traceId 过滤最后一条匹配 (防 lastWriter-wins 污染)
if (!actualSkill) {
try {
const actualJsonl = path.join(DEBUG_DIR, 'actual-skills.jsonl');
if (fs.existsSync(actualJsonl)) {
const lines = fs.readFileSync(actualJsonl, 'utf8').split(/\r?\n/).filter(Boolean);
// 倒序扫描取最后匹配行 (保证取到该 traceId 最近一次 Skill 调用)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const e = JSON.parse(lines[i]);
if (e.traceId === state.traceId && e.actualSkill) {
actualSkill = e.actualSkill;
break;
}
} catch {}
}
}
// 回退: 旧单文件 actual-skill.json (W2: 默认跳过,由 flag 恢复)
if (!actualSkill && process.env.BOOKWORM_LEGACY_ACTUAL_SKILL === '1') {
const actualFile = path.join(DEBUG_DIR, 'actual-skill.json');
if (fs.existsSync(actualFile)) {
const actualData = JSON.parse(fs.readFileSync(actualFile, 'utf8'));
if (actualData.traceId === state.traceId && actualData.actualSkill) {
actualSkill = actualData.actualSkill;
}
}
}
} catch {}
}
// 回退: 从 activity 日志查找 Skill 调用
if (!actualSkill) {
const stateTs = new Date(state.ts).getTime();
if (!isNaN(stateTs)) {
const recentSkills = skillUsages.filter(s => {
const skillTs = new Date(s.ts).getTime();
if (isNaN(skillTs)) return false;
return skillTs >= stateTs && skillTs - stateTs < 5 * 60 * 1000;
});
if (recentSkills.length > 0) {
actualSkill = recentSkills[recentSkills.length - 1].detail;
}
}
}
if (!actualSkill) {
// P2: 对 medium/complex 路由推断隐式接受 (提升 H9 数据密度)
const complexity = state.intent?.complexity;
if ((complexity === 'medium' || complexity === 'complex') && state.routing?.primary) {
return { compliant: 'inferred', actualSkill: state.routing.primary, gateBlocked };
}
// 简单查询无 Skill 调用 → 跳过
return { compliant: 'skipped', actualSkill: null, gateBlocked };
}
// 检查是否匹配路由推荐
const primary = state.routing?.primary;
const candidateNames = (state.routing?.candidates || []).map(c => c.name);
const chainSkills = state.routing?.chain || [];
const allAllowed = new Set([primary, ...candidateNames, ...chainSkills, 'developer-expert']);
if (allAllowed.has(actualSkill)) {
return { compliant: true, actualSkill, gateBlocked };
}
return { compliant: false, actualSkill, gateBlocked };
}
/**
* 写入审计记录
*/
function writeAuditEntry(state, judgment) {
const entry = {
ts: new Date().toISOString(),
traceId: state.traceId,
promptHash: state.promptHash,
intent: {
complexity: state.intent?.complexity,
intents: state.intent?.intents,
},
routedTo: state.routing?.primary,
actualSkill: judgment.actualSkill,
compliant: judgment.compliant,
gateBlocked: judgment.gateBlocked,
confidence: state.routing?.confidence,
};
try {
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
const dateStr = new Date().toISOString().slice(0, 10);
const logFile = path.join(DEBUG_DIR, `compliance-${dateStr}.jsonl`);
fs.appendFileSync(logFile, JSON.stringify(entry) + '\n');
} catch {}
return entry;
}
/**
* 检查是否需要触发 autoLearn
*/
function checkAutoLearn(todayEntries) {
const violations = todayEntries.filter(e =>
e.compliant === false
);
if (violations.length >= 5) {
try {
const routeFeedback = require(path.join(SCRIPTS_DIR, 'route-feedback.js'));
if (routeFeedback.autoLearn) {
routeFeedback.autoLearn();
}
} catch {}
}
}
/**
* 运行隐式反馈推断 (会话结束时触发)
*/
function runImplicitFeedback() {
try {
const implicitFeedback = require(path.join(SCRIPTS_DIR, 'implicit-feedback.js'));
if (implicitFeedback.inferAndWrite) {
implicitFeedback.inferAndWrite({ days: 1 });
}
} catch {
// 模块不存在或异常时静默跳过
}
}
/**
* 清理路由状态文件
*/
/**
* 回写 actualSkill 到当日路由日志
*/
function updateRouteLogWithActual(traceId, actualSkill) {
try {
const dateStr = new Date().toISOString().slice(0, 10);
const logFile = path.join(DEBUG_DIR, "route-" + dateStr + ".jsonl");
if (!fs.existsSync(logFile)) return;
// H11: O_EXCL 文件锁保护全文件 read-modify-write
// P2.2 stale lock: mtime>60s 视为进程崩溃残留,强制释放后重试
const lockFile = logFile + '.lock';
let lockFd;
try { lockFd = fs.openSync(lockFile, 'wx'); } catch {
try {
const lockStat = fs.statSync(lockFile);
if (Date.now() - lockStat.mtimeMs > 60000) {
try { fs.unlinkSync(lockFile); } catch {}
try { lockFd = fs.openSync(lockFile, 'wx'); } catch { return; }
} else { return; }
} catch { return; }
}
try {
const lines = fs.readFileSync(logFile, "utf8").trim().split("\n");
let updated = false;
const newLines = lines.map(line => {
try {
const entry = JSON.parse(line);
if (entry.traceId === traceId && !entry.actualSkill) {
entry.actualSkill = actualSkill;
updated = true;
return JSON.stringify(entry);
}
} catch {}
return line;
});
if (updated) {
const tmpFile = logFile + '.tmp.' + process.pid;
fs.writeFileSync(tmpFile, newLines.join("\n") + "\n");
fs.renameSync(tmpFile, logFile);
}
} finally {
try { fs.closeSync(lockFd); fs.unlinkSync(lockFile); } catch {}
}
} catch {}
}
function cleanupState() {
try {
if (fs.existsSync(STATE_FILE)) {
fs.unlinkSync(STATE_FILE);
}
} catch {}
}
// === 主流程 ===
function main() {
readStdin({ maxSize: 128 * 1024 }).then(input => {
// 读取路由状态
const state = loadRouteState();
if (!state) {
// 无路由记录 → 不审计
process.exit(0);
return;
}
// 读取 compliance 日志
const todayEntries = loadTodayCompliance();
const traceEntries = findTraceEntries(todayEntries, state.traceId);
// 查找 Skill 使用记录
const skillUsages = findSkillUsage();
// 判定合规
const judgment = judgeCompliance(state, traceEntries, skillUsages);
// 写入审计记录
writeAuditEntry(state, judgment);
// [v6.1-PATCH] adaptive-disambiguator feedback
// 反馈闭环: 检测到路由纠正时,更新 Bayesian Dirichlet 先验
// compliant===false 表示用户实际使用的技能与路由推荐不符
if (judgment.compliant === false && judgment.actualSkill && state.routing) {
try {
const adaptiveDisamb = require(require('path').join(
__dirname.replace(/[/\\]hooks$/, ''), 'scripts', 'adaptive-disambiguator.js'
));
const routedTo = state.routing.primary;
const actualSkill = judgment.actualSkill;
// 竞争技能: 路由时的候选列表(排除 routedTo 和 actualSkill 本身)
const competingSkills = (state.routing.candidates || [])
.map(c => c.name)
.filter(n => n !== routedTo && n !== actualSkill);
adaptiveDisamb.updateFromFeedback(routedTo, actualSkill, competingSkills);
} catch {}
}
// 回写 actualSkill 到当日路由日志 (供 implicit-feedback 消费)
if (judgment.actualSkill && state.traceId) {
updateRouteLogWithActual(state.traceId, judgment.actualSkill);
}
// A/B 实验结果回写 (v5.5 闭环)
if (state.routing && state.routing.experiment && state.routing.experiment.id) {
try {
const abTest = require(path.join(SCRIPTS_DIR, "route-ab-test.js"));
const expId = state.routing.experiment.id;
const usedSkill = judgment.actualSkill || state.routing.primary;
// 无纠正 = success合规跳过也算 success (用户接受了路由)
const outcome = (judgment.compliant === false) ? "failure" : "success";
abTest.recordOutcome(expId, usedSkill, outcome);
// 检查是否收敛
abTest.resolveConverged();
} catch {}
}
// v6.4: abVariant 闭环 — 当 route-state 含 abVariant 时补充调用 recordOutcome
// 确保 selectVariant() 的 Thompson Sampling Beta 分布获得反馈更新
if (state.abVariant && state.abVariant.experimentId) {
try {
const abTest = require(path.join(SCRIPTS_DIR, "route-ab-test.js"));
const expId = state.abVariant.experimentId;
// 以实际使用的 skill 作为 outcome 目标;回退到路由推荐的 primary
const usedSkill = judgment.actualSkill || (state.routing && state.routing.primary) || state.abVariant.selected;
// 判定: 用户未纠正路由 = success纠正了 = failure
const outcome = (judgment.compliant === false) ? "failure" : "success";
abTest.recordOutcome(expId, usedSkill, outcome);
abTest.resolveConverged();
} catch {
// fail-open: recordOutcome 失败不影响审计流程
}
}
// 累计检查
const allTodayEntries = loadTodayCompliance(); // 重新加载 (含刚写入的)
checkAutoLearn(allTodayEntries);
// 运行隐式反馈推断
runImplicitFeedback();
// 清理 route-state
cleanupState();
process.exit(0);
}).catch(() => process.exit(0));
}
/**
* 可导出的审计执行函数 (供 dispatcher 调用)
* 不读取 stdin不调用 process.exit
*/
function runAudit() {
try {
const state = loadRouteState();
if (!state) return;
const todayEntries = loadTodayCompliance();
const traceEntries = findTraceEntries(todayEntries, state.traceId);
const skillUsages = findSkillUsage();
const judgment = judgeCompliance(state, traceEntries, skillUsages);
writeAuditEntry(state, judgment);
// adaptive-disambiguator feedback
if (judgment.compliant === false && judgment.actualSkill && state.routing) {
try {
const adaptiveDisamb = require(path.join(
__dirname.replace(/[/\\]hooks$/, ''), 'scripts', 'adaptive-disambiguator.js'
));
const routedTo = state.routing.primary;
const actualSkill = judgment.actualSkill;
const competingSkills = (state.routing.candidates || [])
.map(c => c.name)
.filter(n => n !== routedTo && n !== actualSkill);
adaptiveDisamb.updateFromFeedback(routedTo, actualSkill, competingSkills);
} catch {}
}
if (judgment.actualSkill && state.traceId) {
updateRouteLogWithActual(state.traceId, judgment.actualSkill);
}
// A/B 实验结果回写
if (state.routing && state.routing.experiment && state.routing.experiment.id) {
try {
const abTest = require(path.join(SCRIPTS_DIR, 'route-ab-test.js'));
const expId = state.routing.experiment.id;
const usedSkill = judgment.actualSkill || state.routing.primary;
const outcome = (judgment.compliant === false) ? 'failure' : 'success';
abTest.recordOutcome(expId, usedSkill, outcome);
abTest.resolveConverged();
} catch {}
}
// v6.4: abVariant 闭环 — 当 route-state 含 abVariant 时补充调用 recordOutcome
// 确保 selectVariant() 的 Thompson Sampling Beta 分布获得反馈更新
if (state.abVariant && state.abVariant.experimentId) {
try {
const abTest = require(path.join(SCRIPTS_DIR, 'route-ab-test.js'));
const expId = state.abVariant.experimentId;
const usedSkill = judgment.actualSkill || (state.routing && state.routing.primary) || state.abVariant.selected;
const outcome = (judgment.compliant === false) ? 'failure' : 'success';
abTest.recordOutcome(expId, usedSkill, outcome);
abTest.resolveConverged();
} catch {
// fail-open: recordOutcome 失败不影响审计流程
}
}
const allTodayEntries = loadTodayCompliance();
checkAutoLearn(allTodayEntries);
// GH-4: runImplicitFeedback() 已由 stop-dispatcher 独立调用,此处移除避免双重执行
cleanupState();
} catch {}
}
// 模块导出 (供测试和 dispatcher 使用)
if (typeof module !== 'undefined') {
module.exports = {
detectClaudeRoot: CLAUDE_ROOT,
loadRouteState,
loadTodayCompliance,
findTraceEntries,
findSkillUsage,
judgeCompliance,
writeAuditEntry,
checkAutoLearn,
cleanupState,
runAudit,
};
}
if (require.main === module) {
main();
}