From 31f672275ed52d1249a13a07a8b854042b99f99a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:48:29 -0700 Subject: [PATCH] feat(ecc2): infer tracked write modifications --- scripts/hooks/session-activity-tracker.js | 130 ++++++++++++++++++- tests/hooks/session-activity-tracker.test.js | 52 +++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 554700f7..e802d5d0 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -10,6 +10,7 @@ const crypto = require('crypto'); const path = require('path'); +const { spawnSync } = require('child_process'); const { appendFile, getClaudeDir, @@ -161,6 +162,33 @@ function buildPatchPreviewFromContent(content, prefix) { return lines.map(line => `${prefix} ${line}`).join('\n'); } +function buildDiffPreviewFromPatchPreview(patchPreview) { + if (typeof patchPreview !== 'string' || !patchPreview.trim()) { + return undefined; + } + + const lines = patchPreview + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-')); + const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+')); + + if (!removed && !added) { + return undefined; + } + + const before = removed ? removed.replace(/^- ?/, '') : ''; + const after = added ? added.replace(/^\+ ?/, '') : ''; + if (before && after) { + return `${before} -> ${after}`; + } + if (before) { + return `${before} ->`; + } + return `-> ${after}`; +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -269,6 +297,104 @@ function fileEventPatchPreview(value, action) { return undefined; } +function runGit(args, cwd) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 2500, + }); + + if (result.error || result.status !== 0) { + return null; + } + + return String(result.stdout || '').trim(); +} + +function gitRepoRoot(cwd) { + return runGit(['rev-parse', '--show-toplevel'], cwd); +} + +function repoRelativePath(repoRoot, filePath) { + const absolute = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(process.cwd(), filePath); + const relative = path.relative(repoRoot, absolute); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return relative.split(path.sep).join('/'); +} + +function patchPreviewFromGitDiff(repoRoot, repoRelative) { + const patch = runGit( + ['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative], + repoRoot + ); + if (!patch) { + return undefined; + } + + const relevant = patch + .split(/\r?\n/) + .filter(line => + line.startsWith('@@') + || (line.startsWith('+') && !line.startsWith('+++')) + || (line.startsWith('-') && !line.startsWith('---')) + ) + .slice(0, 6); + + if (relevant.length === 0) { + return undefined; + } + + return relevant.join('\n'); +} + +function trackedInGit(repoRoot, repoRelative) { + return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null; +} + +function enrichFileEventFromWorkingTree(toolName, event) { + if (!event || typeof event !== 'object' || !event.path) { + return event; + } + + const repoRoot = gitRepoRoot(process.cwd()); + if (!repoRoot) { + return event; + } + + const repoRelative = repoRelativePath(repoRoot, event.path); + if (!repoRelative) { + return event; + } + + const tool = String(toolName || '').trim().toLowerCase(); + const tracked = trackedInGit(repoRoot, repoRelative); + const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview; + const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview; + + if (tool.includes('write')) { + return { + ...event, + action: tracked ? 'modify' : event.action, + diff_preview: diffPreview, + patch_preview: patchPreview, + }; + } + + if (tracked && patchPreview) { + return { + ...event, + diff_preview: diffPreview, + patch_preview: patchPreview, + }; + } + + return event; +} + function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; @@ -375,7 +501,9 @@ function buildActivityRow(input, env = process.env) { } const toolInput = input?.tool_input || {}; - const fileEvents = extractFileEvents(toolName, toolInput); + const fileEvents = extractFileEvents(toolName, toolInput).map(event => + enrichFileEventFromWorkingTree(toolName, event) + ); const filePaths = fileEvents.length > 0 ? [...new Set(fileEvents.map(event => event.path))] : extractFilePaths(toolInput); diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 84620c7f..0b15c2fd 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -40,13 +40,14 @@ function withTempHome(homeDir) { }; } -function runScript(input, envOverrides = {}) { +function runScript(input, envOverrides = {}, options = {}) { const inputStr = typeof input === 'string' ? input : JSON.stringify(input); const result = spawnSync('node', [script], { encoding: 'utf8', input: inputStr, timeout: 10000, env: { ...process.env, ...envOverrides }, + cwd: options.cwd, }); return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; } @@ -210,6 +211,55 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('reclassifies tracked Write activity as modify using git diff context', () => { + const tmpHome = makeTempDir(); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-repo-')); + + spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' }); + + const srcDir = path.join(repoDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + const trackedFile = path.join(srcDir, 'app.ts'); + fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8'); + spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' }); + + fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8'); + + const input = { + tool_name: 'Write', + tool_input: { + file_path: 'src/app.ts', + content: 'const count = 2;\n', + }, + tool_output: { output: 'updated src/app.ts' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-write-modify', + }, { + cwd: repoDir, + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/app.ts', + action: 'modify', + diff_preview: 'const count = 1; -> const count = 2;', + patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = {