diff --git a/.github/workflows/agentblame.yml b/.github/workflows/agentblame.yml index 5ea5529..f9e5709 100644 --- a/.github/workflows/agentblame.yml +++ b/.github/workflows/agentblame.yml @@ -28,8 +28,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Install dependencies - run: bun install + - name: Install agentblame + run: npm install -g @mesadev/agentblame - name: Fetch notes, tags, and PR head run: | @@ -39,7 +39,7 @@ jobs: git fetch origin refs/pull/${{ github.event.pull_request.number }}/head:refs/pull/${{ github.event.pull_request.number }}/head 2>/dev/null || echo "Could not fetch PR head" - name: Process merge (transfer notes + update analytics) - run: bun run packages/cli/src/post-merge.ts + run: bun $(npm root -g)/@mesadev/agentblame/dist/post-merge.js env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/package.json b/package.json index ce68cde..265a060 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "agentblame", "displayName": "Agent Blame", "description": "Track AI-generated vs human-written code. Provides git notes storage, CLI, and GitHub PR attribution.", - "version": "0.2.5", + "version": "0.2.6", "private": true, "license": "Apache-2.0", "repository": { diff --git a/packages/chrome/package.json b/packages/chrome/package.json index 18a229d..ff2ac0e 100644 --- a/packages/chrome/package.json +++ b/packages/chrome/package.json @@ -1,6 +1,6 @@ { "name": "@agentblame/chrome", - "version": "0.2.5", + "version": "0.2.6", "description": "Agent Blame Chrome Extension - See AI attribution on GitHub PRs", "private": true, "scripts": { diff --git a/packages/chrome/src/content/analytics-overlay.ts b/packages/chrome/src/content/analytics-overlay.ts index 65e9577..89efa9e 100644 --- a/packages/chrome/src/content/analytics-overlay.ts +++ b/packages/chrome/src/content/analytics-overlay.ts @@ -15,16 +15,16 @@ import { const PAGE_CONTAINER_ID = "agentblame-page-container"; const ORIGINAL_CONTENT_ATTR = "data-agentblame-hidden"; -// Tool color palette - GitHub Primer colors that work in light/dark themes +// Tool color palette - High contrast colors that work in light/dark themes const TOOL_COLOR_PALETTE = [ "#0969da", // Blue - "#8250df", // Purple - "#bf3989", // Pink - "#0a3069", // Dark blue - "#1a7f37", // Green - "#9a6700", // Yellow/brown "#cf222e", // Red - "#6e7781", // Gray + "#1a7f37", // Green + "#8250df", // Purple + "#bf8700", // Gold/Yellow + "#0550ae", // Dark Blue + "#bf3989", // Magenta + "#1b7c83", // Teal ]; /** diff --git a/packages/chrome/src/icons/icon128.png b/packages/chrome/src/icons/icon128.png index e196489..abfabfa 100644 Binary files a/packages/chrome/src/icons/icon128.png and b/packages/chrome/src/icons/icon128.png differ diff --git a/packages/chrome/src/icons/icon16.png b/packages/chrome/src/icons/icon16.png index 9d13f69..e546310 100644 Binary files a/packages/chrome/src/icons/icon16.png and b/packages/chrome/src/icons/icon16.png differ diff --git a/packages/chrome/src/icons/icon48.png b/packages/chrome/src/icons/icon48.png index 8184acf..ae002f4 100644 Binary files a/packages/chrome/src/icons/icon48.png and b/packages/chrome/src/icons/icon48.png differ diff --git a/packages/chrome/src/icons/logo.svg b/packages/chrome/src/icons/logo.svg index 312f655..b5fdc46 100644 --- a/packages/chrome/src/icons/logo.svg +++ b/packages/chrome/src/icons/logo.svg @@ -1,10 +1,14 @@ - - - - - - - - + + + + + + + + + + + + diff --git a/packages/chrome/src/manifest.json b/packages/chrome/src/manifest.json index dba2727..8a59fdd 100644 --- a/packages/chrome/src/manifest.json +++ b/packages/chrome/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Agent Blame", - "version": "0.2.5", + "version": "0.2.6", "description": "See AI-generated vs human-written code on GitHub PRs", "icons": { "16": "icons/icon16.png", diff --git a/packages/cli/package.json b/packages/cli/package.json index e593ef9..4944cbc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/agentblame", - "version": "0.2.5", + "version": "0.2.6", "description": "CLI to track AI-generated vs human-written code", "license": "Apache-2.0", "repository": { diff --git a/packages/cli/src/capture.ts b/packages/cli/src/capture.ts index 25b3b3c..bc8dca1 100644 --- a/packages/cli/src/capture.ts +++ b/packages/cli/src/capture.ts @@ -27,6 +27,9 @@ interface CapturedLine { content: string; hash: string; hashNormalized: string; + lineNumber?: number; + contextBefore?: string; + contextAfter?: string; } interface CapturedEdit { @@ -40,12 +43,24 @@ interface CapturedEdit { contentHashNormalized: string; editType: "addition" | "modification" | "replacement"; oldContent?: string; + sessionId?: string; + toolUseId?: string; } interface CursorPayload { file_path: string; edits?: Array<{ old_string: string; new_string: string }>; model?: string; + conversation_id?: string; + generation_id?: string; +} + +interface StructuredPatchHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; } interface ClaudePayload { @@ -56,6 +71,14 @@ interface ClaudePayload { new_string?: string; content?: string; }; + tool_response?: { + filePath?: string; + originalFile?: string; + structuredPatch?: StructuredPatchHunk[]; + userModified?: boolean; + }; + session_id?: string; + tool_use_id?: string; file_path?: string; old_string?: string; new_string?: string; @@ -137,6 +160,144 @@ function hashLines(content: string): CapturedLine[] { return result; } +/** + * Hash lines with line numbers and context (for Claude structuredPatch) + */ +function hashLinesWithNumbers( + lines: Array<{ content: string; lineNumber: number }>, + allFileLines: string[] +): CapturedLine[] { + const result: CapturedLine[] = []; + + for (const { content, lineNumber } of lines) { + // Skip empty lines + if (!content.trim()) continue; + + // Get context (3 lines before and after) + const contextBefore = allFileLines + .slice(Math.max(0, lineNumber - 4), lineNumber - 1) + .join("\n"); + const contextAfter = allFileLines + .slice(lineNumber, Math.min(allFileLines.length, lineNumber + 3)) + .join("\n"); + + result.push({ + content, + hash: computeHash(content), + hashNormalized: computeNormalizedHash(content), + lineNumber, + contextBefore: contextBefore || undefined, + contextAfter: contextAfter || undefined, + }); + } + + return result; +} + +/** + * Parse Claude Code's structuredPatch to extract added lines with line numbers + */ +function parseStructuredPatch( + hunks: StructuredPatchHunk[], + originalFileLines: string[] +): Array<{ content: string; lineNumber: number }> { + const addedLines: Array<{ content: string; lineNumber: number }> = []; + + for (const hunk of hunks) { + let newLineNumber = hunk.newStart; + + for (const line of hunk.lines) { + if (line.startsWith("+")) { + // Added line - strip the + prefix + addedLines.push({ + content: line.slice(1), + lineNumber: newLineNumber, + }); + newLineNumber++; + } else if (line.startsWith("-")) { + // Deleted line - don't increment new line number + continue; + } else { + // Context line (starts with space) - increment line number + newLineNumber++; + } + } + } + + return addedLines; +} + +/** + * Read a file and return its lines (for Cursor line number derivation) + */ +async function readFileLines(filePath: string): Promise { + try { + const fs = await import("node:fs/promises"); + const content = await fs.readFile(filePath, "utf8"); + return content.split("\n"); + } catch { + return null; + } +} + +/** + * Find where old_string exists in file and return line numbers for new_string + * Returns null if old_string not found (new file or complex edit) + */ +function findEditLocation( + fileLines: string[], + oldString: string, + newString: string +): Array<{ content: string; lineNumber: number }> | null { + if (!oldString) { + // New content with no old string - can't determine line numbers without more context + return null; + } + + const fileContent = fileLines.join("\n"); + const oldIndex = fileContent.indexOf(oldString); + + if (oldIndex === -1) { + return null; + } + + // Count lines before the match to get line number + const linesBefore = fileContent.slice(0, oldIndex).split("\n").length; + const startLine = linesBefore; + + // Calculate what the new file will look like after the edit + const newFileContent = fileContent.replace(oldString, newString); + const newFileLines = newFileContent.split("\n"); + + // Find the added lines by comparing old and new + const addedContent = extractAddedContent(oldString, newString); + if (!addedContent.trim()) { + return null; + } + + const addedLines = addedContent.split("\n").filter(l => l.trim()); + const result: Array<{ content: string; lineNumber: number }> = []; + + // Find each added line in the new content + let searchStart = startLine - 1; + for (const addedLine of addedLines) { + if (!addedLine.trim()) continue; + + for (let i = searchStart; i < newFileLines.length; i++) { + if (newFileLines[i] === addedLine || newFileLines[i].trim() === addedLine.trim()) { + result.push({ + content: addedLine, + lineNumber: i + 1, // 1-indexed + }); + searchStart = i + 1; + break; + } + } + } + + return result.length > 0 ? result : null; +} + // ============================================================================= // Payload Processing // ============================================================================= @@ -174,13 +335,15 @@ function saveEdit(edit: CapturedEdit): void { editType: edit.editType, oldContent: edit.oldContent, lines: edit.lines, + sessionId: edit.sessionId, + toolUseId: edit.toolUseId, }); } -function processCursorPayload( +async function processCursorPayload( payload: CursorPayload, event: string -): CapturedEdit[] { +): Promise { const edits: CapturedEdit[] = []; const timestamp = new Date().toISOString(); @@ -194,6 +357,9 @@ function processCursorPayload( return edits; } + // Read the file to derive line numbers (Cursor doesn't provide them) + const fileLines = await readFileLines(payload.file_path); + for (const edit of payload.edits) { const oldString = edit.old_string || ""; const newString = edit.new_string || ""; @@ -204,8 +370,22 @@ function processCursorPayload( const addedContent = extractAddedContent(oldString, newString); if (!addedContent.trim()) continue; - // Hash each line individually - const lines = hashLines(addedContent); + let lines: CapturedLine[]; + + // Try to derive line numbers if we have the file + if (fileLines && oldString) { + const linesWithNumbers = findEditLocation(fileLines, oldString, newString); + if (linesWithNumbers && linesWithNumbers.length > 0) { + lines = hashLinesWithNumbers(linesWithNumbers, fileLines); + } else { + // Fallback to basic hashing without line numbers + lines = hashLines(addedContent); + } + } else { + // No file or no old_string - hash without line numbers + lines = hashLines(addedContent); + } + if (lines.length === 0) continue; edits.push({ @@ -213,18 +393,14 @@ function processCursorPayload( provider: "cursor", filePath: payload.file_path, model: payload.model || null, - - // Line-level data lines, - - // Aggregate data content: addedContent, contentHash: computeHash(addedContent), contentHashNormalized: computeNormalizedHash(addedContent), - - // Edit context editType: determineEditType(oldString, newString), oldContent: oldString || undefined, + sessionId: payload.conversation_id, + toolUseId: payload.generation_id, }); } @@ -235,13 +411,55 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { const edits: CapturedEdit[] = []; const timestamp = new Date().toISOString(); - // Claude Code has tool_input with the actual content, or it may be at top level + // Claude Code has tool_input with the actual content const toolInput = payload.tool_input; - const filePath = toolInput?.file_path || payload.file_path; + const toolResponse = payload.tool_response; + const filePath = toolResponse?.filePath || toolInput?.file_path || payload.file_path; if (!filePath) return edits; - // Get content from tool_input or top-level payload + // Extract session info for correlation + const sessionId = payload.session_id; + const toolUseId = payload.tool_use_id; + + // If we have structuredPatch, use it for precise line numbers + if (toolResponse?.structuredPatch && toolResponse.structuredPatch.length > 0) { + // Get original file lines for context + const originalFileLines = (toolResponse.originalFile || "").split("\n"); + + // Parse the structured patch to get added lines with line numbers + const addedLinesWithNumbers = parseStructuredPatch( + toolResponse.structuredPatch, + originalFileLines + ); + + if (addedLinesWithNumbers.length === 0) return edits; + + // Hash lines with their line numbers and context + const lines = hashLinesWithNumbers(addedLinesWithNumbers, originalFileLines); + if (lines.length === 0) return edits; + + // Aggregate content + const addedContent = addedLinesWithNumbers.map(l => l.content).join("\n"); + + edits.push({ + timestamp, + provider: "claudeCode", + filePath, + model: "claude", + lines, + content: addedContent, + contentHash: computeHash(addedContent), + contentHashNormalized: computeNormalizedHash(addedContent), + editType: "modification", + sessionId, + toolUseId, + }); + + return edits; + } + + // Fallback: Get content from tool_input or top-level payload const content = toolInput?.content || payload.content; const oldString = toolInput?.old_string || payload.old_string || ""; const newString = toolInput?.new_string || payload.new_string || ""; @@ -256,24 +474,20 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { edits.push({ timestamp, provider: "claudeCode", - filePath: filePath, + filePath, model: "claude", - - // Line-level data lines, - - // Aggregate data - content: content, + content, contentHash: computeHash(content), contentHashNormalized: computeNormalizedHash(content), - - // Edit context editType: "addition", + sessionId, + toolUseId, }); return edits; } - // Handle Edit tool (old_string -> new_string) + // Handle Edit tool (old_string -> new_string) without structuredPatch if (!newString) return edits; const addedContent = extractAddedContent(oldString, newString); @@ -285,20 +499,16 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { edits.push({ timestamp, provider: "claudeCode", - filePath: filePath, + filePath, model: "claude", - - // Line-level data lines, - - // Aggregate data content: addedContent, contentHash: computeHash(addedContent), contentHashNormalized: computeNormalizedHash(addedContent), - - // Edit context editType: determineEditType(oldString, newString), oldContent: oldString || undefined, + sessionId, + toolUseId, }); return edits; @@ -336,7 +546,7 @@ export async function runCapture(): Promise { if (provider === "cursor") { const eventName = event || data.hook_event_name || "afterFileEdit"; - edits = processCursorPayload(payload as CursorPayload, eventName); + edits = await processCursorPayload(payload as CursorPayload, eventName); } else if (provider === "claude") { edits = processClaudePayload(payload as ClaudePayload); } diff --git a/packages/cli/src/lib/database.ts b/packages/cli/src/lib/database.ts index b81ac84..eaa639f 100644 --- a/packages/cli/src/lib/database.ts +++ b/packages/cli/src/lib/database.ts @@ -36,6 +36,9 @@ export interface DbLine { content: string; hash: string; hashNormalized: string; + lineNumber: number | null; + contextBefore: string | null; + contextAfter: string | null; } export interface LineMatchResult { @@ -64,7 +67,9 @@ CREATE TABLE IF NOT EXISTS edits ( old_content TEXT, status TEXT DEFAULT 'pending', matched_commit TEXT, - matched_at TEXT + matched_at TEXT, + session_id TEXT, + tool_use_id TEXT ); -- Lines table (one row per line in an edit) @@ -74,15 +79,20 @@ CREATE TABLE IF NOT EXISTS lines ( content TEXT NOT NULL, hash TEXT NOT NULL, hash_normalized TEXT NOT NULL, + line_number INTEGER, + context_before TEXT, + context_after TEXT, FOREIGN KEY (edit_id) REFERENCES edits(id) ON DELETE CASCADE ); -- Indexes for fast lookup CREATE INDEX IF NOT EXISTS idx_lines_hash ON lines(hash); CREATE INDEX IF NOT EXISTS idx_lines_hash_normalized ON lines(hash_normalized); +CREATE INDEX IF NOT EXISTS idx_lines_line_number ON lines(line_number); CREATE INDEX IF NOT EXISTS idx_edits_status ON edits(status); CREATE INDEX IF NOT EXISTS idx_edits_file_path ON edits(file_path); CREATE INDEX IF NOT EXISTS idx_edits_content_hash ON edits(content_hash); +CREATE INDEX IF NOT EXISTS idx_edits_session_id ON edits(session_id); `; // ============================================================================= @@ -189,6 +199,8 @@ export interface InsertEditParams { editType: string; oldContent?: string; lines: CapturedLine[]; + sessionId?: string; + toolUseId?: string; } /** @@ -200,8 +212,9 @@ export function insertEdit(params: InsertEditParams): number { const editStmt = db.prepare(` INSERT INTO edits ( timestamp, provider, file_path, model, content, - content_hash, content_hash_normalized, edit_type, old_content - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + content_hash, content_hash_normalized, edit_type, old_content, + session_id, tool_use_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = editStmt.run( @@ -213,19 +226,29 @@ export function insertEdit(params: InsertEditParams): number { params.contentHash, params.contentHashNormalized, params.editType, - params.oldContent || null + params.oldContent || null, + params.sessionId || null, + params.toolUseId || null ); const editId = Number(result.lastInsertRowid); - // Insert lines + // Insert lines with line numbers and context const lineStmt = db.prepare(` - INSERT INTO lines (edit_id, content, hash, hash_normalized) - VALUES (?, ?, ?, ?) + INSERT INTO lines (edit_id, content, hash, hash_normalized, line_number, context_before, context_after) + VALUES (?, ?, ?, ?, ?, ?, ?) `); for (const line of params.lines) { - lineStmt.run(editId, line.content, line.hash, line.hashNormalized); + lineStmt.run( + editId, + line.content, + line.hash, + line.hashNormalized, + line.lineNumber || null, + line.contextBefore || null, + line.contextAfter || null + ); } return editId; @@ -249,7 +272,7 @@ export function findByExactHash( const sameFileStmt = db.prepare(` SELECT l.id as line_id, l.edit_id, l.content as line_content, - l.hash, l.hash_normalized, + l.hash, l.hash_normalized, l.line_number, l.context_before, l.context_after, e.* FROM lines l JOIN edits e ON l.edit_id = e.id @@ -270,7 +293,7 @@ export function findByExactHash( const anyStmt = db.prepare(` SELECT l.id as line_id, l.edit_id, l.content as line_content, - l.hash, l.hash_normalized, + l.hash, l.hash_normalized, l.line_number, l.context_before, l.context_after, e.* FROM lines l JOIN edits e ON l.edit_id = e.id @@ -291,6 +314,9 @@ export function findByExactHash( content: row.line_content, hash: row.hash, hashNormalized: row.hash_normalized, + lineNumber: row.line_number, + contextBefore: row.context_before, + contextAfter: row.context_after, }, matchType: "exact_hash", confidence: 1.0, @@ -312,7 +338,7 @@ export function findByNormalizedHash( const sameFileStmt = db.prepare(` SELECT l.id as line_id, l.edit_id, l.content as line_content, - l.hash, l.hash_normalized, + l.hash, l.hash_normalized, l.line_number, l.context_before, l.context_after, e.* FROM lines l JOIN edits e ON l.edit_id = e.id @@ -331,7 +357,7 @@ export function findByNormalizedHash( const anyStmt = db.prepare(` SELECT l.id as line_id, l.edit_id, l.content as line_content, - l.hash, l.hash_normalized, + l.hash, l.hash_normalized, l.line_number, l.context_before, l.context_after, e.* FROM lines l JOIN edits e ON l.edit_id = e.id @@ -352,6 +378,9 @@ export function findByNormalizedHash( content: row.line_content, hash: row.hash, hashNormalized: row.hash_normalized, + lineNumber: row.line_number, + contextBefore: row.context_before, + contextAfter: row.context_after, }, matchType: "normalized_hash", confidence: 0.95, @@ -390,70 +419,19 @@ export function getEditLines(editId: number): DbLine[] { content: row.content, hash: row.hash, hashNormalized: row.hash_normalized, + lineNumber: row.line_number, + contextBefore: row.context_before, + contextAfter: row.context_after, })); } /** - * Find match using substring containment (Strategy 3 & 4) - * Only called when hash matches fail - */ -export function findBySubstring( - lineContent: string, - filePath: string -): LineMatchResult | null { - const normalizedLine = lineContent.trim(); - - // Skip trivial lines - if (normalizedLine.length <= 10) return null; - - const edits = findEditsByFile(filePath); - - // Strategy 3: Line contained in AI edit content - for (const edit of edits) { - if (edit.content.includes(normalizedLine)) { - return { - edit, - line: { - id: 0, - editId: edit.id, - content: normalizedLine, - hash: "", - hashNormalized: "", - }, - matchType: "line_in_ai_content", - confidence: 0.9, - }; - } - } - - // Strategy 4: AI content contained in line - for (const edit of edits) { - const lines = getEditLines(edit.id); - for (const aiLine of lines) { - const trimmedAiLine = aiLine.content.trim(); - if (trimmedAiLine.length > 10 && normalizedLine.includes(trimmedAiLine)) { - const ratio = trimmedAiLine.length / normalizedLine.length; - if (ratio > 0.5) { - return { - edit, - line: aiLine, - matchType: "ai_content_in_line", - confidence: 0.85, - }; - } - } - } - } - - return null; -} - -/** - * Find a line match using the priority strategy: - * 1. Exact hash match (1.0) - * 2. Normalized hash match (0.95) - * 3. Line contained in AI content (0.9) - * 4. AI content contained in line (0.85) + * Find a line match using exact matching only: + * 1. Exact hash match (confidence: 1.0) + * 2. Normalized hash match (confidence: 0.95) - handles formatter whitespace changes + * + * No substring/fuzzy matching - if hash doesn't match, it's human code. + * Philosophy: "If user modified AI code, it's human code" */ export function findLineMatch( lineContent: string, @@ -461,18 +439,15 @@ export function findLineMatch( lineHashNormalized: string, filePath: string ): LineMatchResult | null { - // Strategy 1: Exact hash + // Strategy 1: Exact hash - perfect match let match = findByExactHash(lineHash, filePath); if (match) return match; - // Strategy 2: Normalized hash + // Strategy 2: Normalized hash - handles whitespace changes from formatters match = findByNormalizedHash(lineHashNormalized, filePath); if (match) return match; - // Strategy 3 & 4: Substring matching (expensive, only if hashes fail) - match = findBySubstring(lineContent, filePath); - if (match) return match; - + // No match = human code (either written by human or modified from AI) return null; } diff --git a/packages/cli/src/lib/hooks.ts b/packages/cli/src/lib/hooks.ts index 2456e54..4620ade 100644 --- a/packages/cli/src/lib/hooks.ts +++ b/packages/cli/src/lib/hooks.ts @@ -7,7 +7,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getDistDir } from "./util"; /** * Get the Cursor hooks.json path for a repo. @@ -26,14 +25,14 @@ export function getClaudeSettingsPath(repoRoot: string): string { /** * Generate the hook command for a given provider. - * Uses bunx to run the agentblame capture command, which works on any machine. + * Uses the globally installed agentblame command. */ function getHookCommand( provider: "cursor" | "claude", event?: string ): string { const eventArg = event ? ` --event ${event}` : ""; - return `bunx @mesadev/agentblame capture --provider ${provider}${eventArg}`; + return `agentblame capture --provider ${provider}${eventArg}`; } /** @@ -219,19 +218,16 @@ export async function installAllHooks( /** * Install git post-commit hook to auto-process commits (per-repo) + * Always installs/updates the hook - removes old agentblame section if present and adds latest */ export async function installGitHook(repoRoot: string): Promise { const hooksDir = path.join(repoRoot, ".git", "hooks"); const hookPath = path.join(hooksDir, "post-commit"); - // Find the CLI script in the dist/ directory (always run compiled .js) - const distDir = getDistDir(__dirname); - const cliScript = path.resolve(distDir, "index.js"); - + // Use the globally installed agentblame command const hookContent = `#!/bin/sh # Agent Blame - Auto-process commits for AI attribution -# Process the commit and attach attribution notes -bun run "${cliScript}" process HEAD 2>/dev/null || true +agentblame process HEAD 2>/dev/null || true # Push notes to remote (silently fails if no notes or no remote) git push origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || true @@ -248,14 +244,14 @@ git push origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || true // File doesn't exist } - // Don't overwrite if already has agentblame - if (existingContent.includes("agentblame")) { - return true; + // Remove old agentblame section if present (to update to latest) + if (existingContent.includes("agentblame") || existingContent.includes("Agent Blame")) { + existingContent = removeAgentBlameSection(existingContent); } // Append to existing hook or create new - if (existingContent && !existingContent.includes("agentblame")) { - // Append to existing hook + if (existingContent.trim()) { + // Append to existing hook (preserves user's other hooks) const newContent = existingContent.trimEnd() + "\n\n" + hookContent.split("\n").slice(1).join("\n"); await fs.promises.writeFile(hookPath, newContent, { mode: 0o755 }); } else { @@ -270,6 +266,39 @@ git push origin refs/notes/agentblame:refs/notes/agentblame 2>/dev/null || true } } +/** + * Remove Agent Blame section from hook content (for updates) + * Removes all agentblame-related lines including the notes push comment + */ +function removeAgentBlameSection(content: string): string { + const lines = content.split("\n"); + const result: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const nextLine = lines[i + 1] || ""; + + // Skip lines containing agentblame or Agent Blame + if (line.includes("agentblame") || line.includes("Agent Blame")) { + continue; + } + + // Skip "Push notes to remote" comment if followed by agentblame notes push + if (line.includes("Push notes to remote") && nextLine.includes("refs/notes/agentblame")) { + continue; + } + + // Skip consecutive empty lines + if (line.trim() === "" && result.length > 0 && result[result.length - 1].trim() === "") { + continue; + } + + result.push(line); + } + + return result.join("\n"); +} + /** * Uninstall git post-commit hook */ @@ -283,21 +312,23 @@ export async function uninstallGitHook(repoRoot: string): Promise { const content = await fs.promises.readFile(hookPath, "utf8"); - if (!content.includes("agentblame")) { + if (!content.includes("agentblame") && !content.includes("Agent Blame")) { return true; // Not our hook } - // Remove agentblame lines - const lines = content.split("\n"); - const newLines = lines.filter( - (line) => !line.includes("agentblame") && !line.includes("Agent Blame") + // Remove agentblame section + const newContent = removeAgentBlameSection(content); + + // Check if only shebang/empty lines left + const meaningfulLines = newContent.split("\n").filter( + (l) => l.trim() && !l.startsWith("#!") ); - if (newLines.filter((l) => l.trim() && !l.startsWith("#!")).length === 0) { + if (meaningfulLines.length === 0) { // Only shebang left, delete the file await fs.promises.unlink(hookPath); } else { - await fs.promises.writeFile(hookPath, newLines.join("\n"), { mode: 0o755 }); + await fs.promises.writeFile(hookPath, newContent, { mode: 0o755 }); } return true; diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 9be66e4..2724a7f 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -21,12 +21,11 @@ export type AttributionCategory = "ai_generated"; /** * How the match was determined + * Only exact matching - no fuzzy/substring matching */ export type MatchType = | "exact_hash" // Line hash matches exactly (confidence: 1.0) - | "normalized_hash" // Normalized hash matches (confidence: 0.95) - | "line_in_ai_content" // Line found within AI edit (confidence: 0.9) - | "ai_content_in_line" // AI content found in line (confidence: 0.85) + | "normalized_hash" // Normalized hash matches, handles formatter whitespace (confidence: 0.95) | "move_detected"; // Line was moved from AI-attributed location (confidence: 0.85) // ============================================================================= @@ -40,6 +39,9 @@ export interface CapturedLine { content: string; hash: string; hashNormalized: string; + lineNumber?: number; // Line number in the file (1-indexed) + contextBefore?: string; // 3 lines before for disambiguation + contextAfter?: string; // 3 lines after for disambiguation } // =============================================================================