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();
|
|||
|
|
}
|