bookworm-smart-assistant/hooks/activity-logger.js

171 lines
5.8 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* PostToolUse Hook: 活动日志记录器
* 匹配器: Edit|Write|Skill|Agent|Bash|mcp__.*
* 触发: 关键工具调用完成后记录到 JSONL
* 退出码: 始终 0 (纯记录不阻断工作流)
*
* 记录的事件类型:
* skill Skill 路由调用
* agent Agent 子进程 spawn / EnterWorktree
* agent Agent spawn / TaskCreate/TaskUpdate / EnterWorktree (统一为 agent 保持下游兼容)
* mcp MCP 服务器工具调用
* bash Bash 命令执行
* write 文件写入/编辑操作
*
* 日志文件: debug/activity-YYYY-MM-DD.jsonl
*/
const fs = require('fs');
const path = require('path');
const { safeAppendJsonl } = require('./lib/safe-append.js');
const readStdin = require('./lib/read-stdin.js');
// 动态检测 Claude 配置根目录(兼容不同用户名)
const CLAUDE_ROOT = require('./lib/root.js');
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
/**
* 日志脱敏: 使用共享脱敏模块 (v5.9 统一规则)
*/
const sanitizeForLog = (() => {
try { return require('../scripts/sanitize.js').sanitize; }
catch {
// 回退: 共享模块缺失时使用内联最小脱敏
return (text) => {
if (!text || typeof text !== 'string') return text || '';
return text
.replace(/(?:key|token|password|secret)[\s]*[=:]\s*\S{6,}/gi, '[REDACTED]')
.replace(/\b(?:sk-|ghp_|Bearer\s+)\S{10,}/g, '[REDACTED_TOKEN]');
};
}
})();
// 事件分类规则
function classifyEvent(toolName) {
if (toolName === 'Skill') return 'skill';
if (toolName === 'Agent') return 'agent';
if (toolName === 'TaskCreate' || toolName === 'TaskUpdate') return 'agent'; // LV-04: 保留 'agent' 兼容下游消费者
if (toolName.startsWith('mcp__')) return 'mcp';
if (toolName === 'Bash') return 'bash';
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') return 'write';
if (toolName === 'EnterWorktree') return 'agent';
return null; // 未知工具不记录
}
// 提取事件详情
function extractDetail(event, toolName, toolInput) {
switch (event) {
case 'skill':
return (toolInput && toolInput.skill) || 'unknown';
case 'agent':
if (toolName === 'Agent') {
// Agent 工具: 记录 subagent_type + description
const agentType = (toolInput && toolInput.subagent_type) || 'general-purpose';
const desc = (toolInput && toolInput.description) || '';
return `${agentType}${desc ? ':' + desc.slice(0, 80) : ''}`;
}
if (toolName === 'TaskCreate') {
return (toolInput && toolInput.subject) || 'new-task';
}
if (toolName === 'TaskUpdate') {
return toolInput
? `${toolInput.taskId || '?'}:${toolInput.status || 'update'}`
: 'update';
}
if (toolName === 'EnterWorktree') {
return (toolInput && toolInput.name) || 'worktree';
}
return '';
case 'mcp': {
// mcp__server__action → server/action
const parts = toolName.replace(/^mcp__/, '').split('__');
return parts.join('/');
}
case 'bash': {
const cmd = (toolInput && toolInput.command) || '';
return cmd.slice(0, 120);
}
case 'write':
return (toolInput && (toolInput.file_path || toolInput.filePath || toolInput.notebook_path)) || '';
default:
return '';
}
}
function main() {
readStdin({ maxSize: 512 * 1024 }).then(input => {
const toolName = input.tool_name;
if (!toolName) { process.exit(0); return; }
const event = classifyEvent(toolName);
if (!event) { process.exit(0); return; }
const toolInput = input.tool_input || {};
const toolResult = input.tool_result || '';
const rawDetail = extractDetail(event, toolName, toolInput);
const detail = sanitizeForLog(rawDetail);
// 推断执行结果: 检查 tool_result 是否包含错误信号
const resultStr = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
const hasError = /\b(error|Error|ERROR|failed|FAILED|exception|Exception)\b/.test(
resultStr.slice(0, 500)
);
// v5.9: 从 route-state-current.json 读取 traceId + mustInvoke实现端到端追踪
let traceId = null;
let mustInvoke = false;
try {
const stateFile = path.join(DEBUG_DIR, 'route-state-current.json');
if (fs.existsSync(stateFile)) {
const st = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
traceId = st.traceId || null;
mustInvoke = st.mustInvoke || false; // Phase 4.4: 关联 MUST_INVOKE 标记
}
} catch {}
const logEntry = {
ts: new Date().toISOString(),
event,
tool: toolName,
detail,
phase: 'post',
success: !hasError,
sessionId: input.session_id || null,
traceId,
mustInvoke,
};
// 按日期分文件,安全追加 (高频写入启用文件锁)
const dateStr = new Date().toISOString().slice(0, 10);
const logFile = path.join(DEBUG_DIR, `activity-${dateStr}.jsonl`);
safeAppendJsonl(logFile, logEntry, { useLock: true });
// v5.9: Skill 执行结果观测 — 写入直接反馈信号
if (event === 'skill' && traceId && detail) {
try {
const signalFile = path.join(DEBUG_DIR, 'skill-outcome.jsonl');
const signal = {
ts: logEntry.ts,
traceId,
skill: detail,
success: logEntry.success,
type: 'observed',
};
safeAppendJsonl(signalFile, signal);
} catch {}
}
process.exit(0);
}).catch(() => process.exit(0));
}
// 模块导出 (供测试使用)
if (typeof module !== 'undefined') {
module.exports = { CLAUDE_ROOT, classifyEvent, extractDetail, sanitizeForLog };
}
if (require.main === module) {
main();
}