- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
#!/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();
|
||
}
|