#!/usr/bin/env node /** * Session Activity Tracker Hook * * PostToolUse hook that records sanitized per-tool activity to * ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync. */ 'use strict'; const crypto = require('crypto'); const path = require('path'); const { appendFile, getClaudeDir, stripAnsi, } = require('../lib/utils'); const MAX_STDIN = 1024 * 1024; const METRICS_FILE_NAME = 'tool-usage.jsonl'; const FILE_PATH_KEYS = new Set([ 'file_path', 'file_paths', 'source_path', 'destination_path', 'old_file_path', 'new_file_path', ]); function redactSecrets(value) { return String(value || '') .replace(/\n/g, ' ') .replace(/--token[= ][^ ]*/g, '--token=') .replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:') .replace(/\bAKIA[A-Z0-9]{16}\b/g, '') .replace(/\bASIA[A-Z0-9]{16}\b/g, '') .replace(/password[= ][^ ]*/gi, 'password=') .replace(/\bghp_[A-Za-z0-9_]+\b/g, '') .replace(/\bgho_[A-Za-z0-9_]+\b/g, '') .replace(/\bghs_[A-Za-z0-9_]+\b/g, '') .replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, ''); } function truncateSummary(value, maxLength = 220) { const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' '); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength - 3)}...`; } function pushPathCandidate(paths, value) { const candidate = String(value || '').trim(); if (!candidate) { return; } if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { return; } if (!paths.includes(candidate)) { paths.push(candidate); } } function pushFileEvent(events, value, action, diffPreview, patchPreview) { const candidate = String(value || '').trim(); if (!candidate) { return; } if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { return; } const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim() ? diffPreview.trim() : undefined; const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim() ? patchPreview.trim() : undefined; if (!events.some(event => event.path === candidate && event.action === action && (event.diff_preview || undefined) === normalizedDiffPreview && (event.patch_preview || undefined) === normalizedPatchPreview )) { const event = { path: candidate, action }; if (normalizedDiffPreview) { event.diff_preview = normalizedDiffPreview; } if (normalizedPatchPreview) { event.patch_preview = normalizedPatchPreview; } events.push(event); } } function sanitizeDiffText(value, maxLength = 96) { if (typeof value !== 'string' || !value.trim()) { return ''; } return truncateSummary(value, maxLength); } function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) { if (typeof value !== 'string' || !value.trim()) { return []; } return stripAnsi(redactSecrets(value)) .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .slice(0, maxLines) .map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`); } function buildReplacementPreview(oldValue, newValue) { const before = sanitizeDiffText(oldValue); const after = sanitizeDiffText(newValue); if (!before && !after) { return undefined; } if (!before) { return `-> ${after}`; } if (!after) { return `${before} ->`; } return `${before} -> ${after}`; } function buildCreationPreview(content) { const normalized = sanitizeDiffText(content); if (!normalized) { return undefined; } return `+ ${normalized}`; } function buildPatchPreviewFromReplacement(oldValue, newValue) { const beforeLines = sanitizePatchLines(oldValue); const afterLines = sanitizePatchLines(newValue); if (beforeLines.length === 0 && afterLines.length === 0) { return undefined; } const lines = ['@@']; for (const line of beforeLines) { lines.push(`- ${line}`); } for (const line of afterLines) { lines.push(`+ ${line}`); } return lines.join('\n'); } function buildPatchPreviewFromContent(content, prefix) { const lines = sanitizePatchLines(content); if (lines.length === 0) { return undefined; } return lines.map(line => `${prefix} ${line}`).join('\n'); } function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { return 'read'; } if (normalized.includes('write')) { return 'create'; } if (normalized.includes('edit')) { return 'modify'; } if (normalized.includes('delete') || normalized.includes('remove')) { return 'delete'; } if (normalized.includes('move') || normalized.includes('rename')) { return 'move'; } return 'touch'; } function actionForFileKey(toolName, key) { if (key === 'source_path' || key === 'old_file_path') { return 'move'; } if (key === 'destination_path' || key === 'new_file_path') { return 'move'; } return inferDefaultFileAction(toolName); } function collectFilePaths(value, paths) { if (!value) { return; } if (Array.isArray(value)) { for (const entry of value) { collectFilePaths(entry, paths); } return; } if (typeof value === 'string') { pushPathCandidate(paths, value); return; } if (typeof value !== 'object') { return; } for (const [key, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(key)) { collectFilePaths(nested, paths); continue; } if (nested && (Array.isArray(nested) || typeof nested === 'object')) { collectFilePaths(nested, paths); } } } function extractFilePaths(toolInput) { const paths = []; if (!toolInput || typeof toolInput !== 'object') { return paths; } collectFilePaths(toolInput, paths); return paths; } function fileEventDiffPreview(toolName, value, action) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { return buildReplacementPreview(value.old_string, value.new_string); } if (action === 'create') { return buildCreationPreview(value.content || value.file_text || value.text); } return undefined; } function fileEventPatchPreview(value, action) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { return buildPatchPreviewFromReplacement(value.old_string, value.new_string); } if (action === 'create') { return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+'); } if (action === 'delete') { return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-'); } return undefined; } function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; } if (Array.isArray(value)) { for (const entry of value) { collectFileEvents(toolName, entry, events, key, parentValue); } return; } if (typeof value === 'string') { if (key && FILE_PATH_KEYS.has(key)) { const action = actionForFileKey(toolName, key); pushFileEvent( events, value, action, fileEventDiffPreview(toolName, parentValue, action), fileEventPatchPreview(parentValue, action) ); } return; } if (typeof value !== 'object') { return; } for (const [nestedKey, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(nestedKey)) { collectFileEvents(toolName, nested, events, nestedKey, value); continue; } if (nested && (Array.isArray(nested) || typeof nested === 'object')) { collectFileEvents(toolName, nested, events, null, nested); } } } function extractFileEvents(toolName, toolInput) { const events = []; if (!toolInput || typeof toolInput !== 'object') { return events; } collectFileEvents(toolName, toolInput, events); return events; } function summarizeInput(toolName, toolInput, filePaths) { if (toolName === 'Bash') { return truncateSummary(toolInput?.command || 'bash'); } if (filePaths.length > 0) { return truncateSummary(`${toolName} ${filePaths.join(', ')}`); } if (toolInput && typeof toolInput === 'object') { const shallow = {}; for (const [key, value] of Object.entries(toolInput)) { if (value == null) { continue; } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { shallow[key] = value; } } const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName; return truncateSummary(serialized); } return truncateSummary(toolName); } function summarizeOutput(toolOutput) { if (toolOutput == null) { return ''; } if (typeof toolOutput === 'string') { return truncateSummary(toolOutput); } if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') { return truncateSummary(toolOutput.output); } return truncateSummary(JSON.stringify(toolOutput)); } function buildActivityRow(input, env = process.env) { const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim(); if (hookEvent && hookEvent !== 'PostToolUse') { return null; } const toolName = String(input?.tool_name || '').trim(); const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim(); if (!toolName || !sessionId) { return null; } const toolInput = input?.tool_input || {}; const fileEvents = extractFileEvents(toolName, toolInput); const filePaths = fileEvents.length > 0 ? [...new Set(fileEvents.map(event => event.path))] : extractFilePaths(toolInput); return { id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, timestamp: new Date().toISOString(), session_id: sessionId, tool_name: toolName, input_summary: summarizeInput(toolName, toolInput, filePaths), output_summary: summarizeOutput(input?.tool_output), duration_ms: 0, file_paths: filePaths, file_events: fileEvents, }; } function run(rawInput) { try { const input = rawInput.trim() ? JSON.parse(rawInput) : {}; const row = buildActivityRow(input); if (row) { appendFile( path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME), `${JSON.stringify(row)}\n` ); } } catch { // Keep hook non-blocking. } return rawInput; } function main() { let raw = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { const remaining = MAX_STDIN - raw.length; raw += chunk.substring(0, remaining); } }); process.stdin.on('end', () => { process.stdout.write(run(raw)); }); } if (require.main === module) { main(); } module.exports = { buildActivityRow, extractFileEvents, extractFilePaths, summarizeInput, summarizeOutput, run, };