171 lines
5.8 KiB
JavaScript
171 lines
5.8 KiB
JavaScript
#!/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();
|
||
}
|