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
}
// =============================================================================