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

476 lines
16 KiB
JavaScript
Raw Permalink Normal View History

#!/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`);
// C2_SAFE_APPEND_v1: 使用 safeAppendJsonl 文件锁防并发损坏
try {
const { safeAppendJsonl } = require('./lib/safe-append.js');
safeAppendJsonl(logFile, entry, { useLock: true });
} catch {
try { fs.appendFileSync(logFile, JSON.stringify(entry) + '\n'); } catch {}
}
} 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();
}