Skip to content

Commit 64890a9

Browse files
codeprakhar25claude
andcommitted
feat: orphan-branch storage, multi-agent log display, disable auto-amend (v0.1.17)
- finalize-ledger.py: write ledger to agentdiff-meta orphan branch via git plumbing (hash-object → mktree → commit-tree → update-ref); fall back to working-tree append if plumbing unavailable - store.rs: read from agentdiff-meta:ledger.jsonl first, fall back to .agentdiff/ledger.jsonl for existing repos - log.rs: group entries by commit, show all distinct agents per commit with their respective files (fixes multi-agent attribution display) - config.rs: default auto_amend_ledger to false — stops amend noise on every commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fa3b6a4 commit 64890a9

7 files changed

Lines changed: 283 additions & 59 deletions

File tree

‎.agentdiff/ledger.jsonl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
{"agent":"human","author":"Prakhar Khatri","files_read":[],"files_touched":[".github/ISSUE_TEMPLATE/agent_support.yml",".github/ISSUE_TEMPLATE/bug_report.yml",".github/ISSUE_TEMPLATE/config.yml",".github/ISSUE_TEMPLATE/feature_request.yml",".github/PULL_REQUEST_TEMPLATE.md"],"flags":[],"lines":{".github/ISSUE_TEMPLATE/agent_support.yml":[[1,35]],".github/ISSUE_TEMPLATE/bug_report.yml":[[1,91]],".github/ISSUE_TEMPLATE/config.yml":[[1,5]],".github/ISSUE_TEMPLATE/feature_request.yml":[[1,25]],".github/PULL_REQUEST_TEMPLATE.md":[[1,25]]},"model":"human","prompt_excerpt":"","prompt_hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","session_id":"unknown","sha":"532365281bc340476452822acc4e25806b50eef8","sig":{"alg":"ed25519","key_id":"e73a216a1030275f","value":"g8/MHljohjPcSTesqWurcmhaMk9qyCT3fiLIBPmJ0KU3bAUhJvt3k0O26IggGxLRPLZ5mIank0xplDckd7SgBQ=="},"tool":"commit","ts":"2026-03-31T10:38:32+00:00"}
2121
{"sha":"9d10d5d3a9dbc2e0d30bc48e51ea8737c4967f35","ts":"2026-04-02T13:37:06+00:00","agent":"claude-code","model":"unknown","session_id":"6219267e-7119-45f9-92be-aa9967be8b22","author":"Prakhar Khatri","files_touched":[".github/workflows/agentdiff-policy.yml",".github/workflows/ci.yml","Cargo.lock","Cargo.toml","TODOS.md","scripts/prepare-ledger.py","scripts/vscode-extension/extension.js","src/cli.rs","src/commands/export.rs","src/commands/keys.rs","src/commands/ledger.rs","src/commands/mod.rs","src/commands/policy.rs","src/commands/sign_entry.rs","src/commands/status.rs","src/commands/verify.rs","src/data.rs","src/init.rs","src/keys.rs","src/main.rs","src/store.rs"],"lines":{".github/workflows/agentdiff-policy.yml":[[1,39]],".github/workflows/ci.yml":[[34,41]],"Cargo.lock":[[11,11],[16,16],[18,19],[21,21],[125,136],[255,260],[286,322],[360,384],[413,418],[574,584],[663,672],[748,756],[797,805],[825,830],[906,924],[931,936],[1480,1485]],"Cargo.toml":[[41,45]],"TODOS.md":[[1,52]],"scripts/prepare-ledger.py":[[139,145],[157,161],[186,198],[227,236],[270,277],[296,297]],"scripts/vscode-extension/extension.js":[[13,20],[120,123]],"src/cli.rs":[[56,74],[263,335]],"src/commands/export.rs":[[1,127]],"src/commands/keys.rs":[[1,16]],"src/commands/ledger.rs":[[152,152]],"src/commands/mod.rs":[[4,5],[9,9],[12,12],[14,14],[16,16]],"src/commands/policy.rs":[[1,323]],"src/commands/sign_entry.rs":[[1,58]],"src/commands/status.rs":[[1,138]],"src/commands/verify.rs":[[1,185]],"src/data.rs":[[111,121],[169,171]],"src/init.rs":[[281,283],[297,301]],"src/keys.rs":[[1,273]],"src/main.rs":[[6,6],[16,20],[28,28],[80,87]],"src/store.rs":[[120,168]]},"prompt_excerpt":"unknown","prompt_hash":"b23a6a8439c0dde5515893e7c90c1e3233b8616e634470f20dc4928bcf3609bc","files_read":[],"flags":[],"tool":"Edit","attribution":{"src/store.rs":{"agent":"copilot","model":"gpt-4o","session_id":"vscode-1775120273252","tool":"copilot-inline"}}}
2222
{"sha":"70c41424bb99a9fafb75584ce2416b83adf5cad8","ts":"2026-04-06T07:25:23+00:00","agent":"codex","model":"gpt-5.4","session_id":"019d550b-2f4d-7b01-b734-e177dfff304a","author":"Prakhar Khatri","files_touched":["Cargo.lock","Cargo.toml","scripts/capture-antigravity.py","scripts/capture-codex.py","scripts/capture-windsurf.py","scripts/prepare-ledger.py","scripts/vscode-extension/extension.js"],"lines":{"Cargo.lock":[[7,7],[11,11],[16,16],[18,19],[21,21],[125,136],[255,260],[286,322],[360,384],[413,418],[574,584],[663,672],[748,756],[797,805],[825,830],[906,924],[931,936],[1480,1485]],"Cargo.toml":[[3,3],[41,45]],"scripts/capture-antigravity.py":[[275,287]],"scripts/capture-codex.py":[[455,465],[475,476]],"scripts/capture-windsurf.py":[[145,155]],"scripts/prepare-ledger.py":[[139,145],[157,161],[186,198],[227,236],[270,277],[296,297]],"scripts/vscode-extension/extension.js":[[13,20],[120,123]]},"prompt_excerpt":"`use-cases` is the better channel if the post is about what people can do with it and why it matters. `api-projects` fits better if you want to emphasize imp...","prompt_hash":"c641b0b63aa4d7db929b051d190967982fb92c1b236ce9bac81a834c3cfdde79","files_read":[],"flags":[],"tool":"task_complete","mode":"agent","attribution":{"Cargo.toml":{"agent":"claude-code","model":"unknown","session_id":"6219267e-7119-45f9-92be-aa9967be8b22","tool":"Edit"}}}
23+
{"sha":"d755fd138a71bbe2f6794439816663b29df28eb3","ts":"2026-04-07T06:44:40+00:00","agent":"claude-code","model":"unknown","session_id":"6219267e-7119-45f9-92be-aa9967be8b22","author":"Prakhar Khatri","files_touched":["Cargo.lock","Cargo.toml","scripts/finalize-ledger.py","src/commands/log.rs","src/config.rs","src/store.rs"],"lines":{"Cargo.lock":[[7,7]],"Cargo.toml":[[3,3]],"scripts/finalize-ledger.py":[[3,7],[16,17],[42,142],[147,147],[179,186],[199,199],[223,233],[238,238]],"src/commands/log.rs":[[3,3],[6,6],[18,33],[37,37],[42,49],[51,51],[53,54],[61,63],[67,99],[101,101],[108,108]],"src/config.rs":[[42,42],[53,53]],"src/store.rs":[[21,43],[45,49],[103,106],[119,119],[140,186]]},"prompt_excerpt":"unknown","prompt_hash":"b23a6a8439c0dde5515893e7c90c1e3233b8616e634470f20dc4928bcf3609bc","files_read":[],"flags":[],"tool":"Edit"}

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "agentdiff"
3-
version = "0.1.16"
3+
version = "0.1.17"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Audit and trace autonomous AI code contributions in git repositories"

‎scripts/finalize-ledger.py‎

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
22
"""
3-
Finalize pending ledger snapshot after commit and append to ledger.jsonl.
3+
Finalize pending ledger snapshot after commit.
4+
5+
Writes to the agentdiff-meta branch (enterprise storage, no working-tree
6+
pollution) via git plumbing. Falls back to appending to ledger.jsonl on disk
7+
if the git plumbing fails (e.g. first-time setup, no git binary on PATH).
48
59
Usage:
610
finalize-ledger.py <repo_root> <pending_ledger> <pending_context> <ledger_path>
@@ -9,7 +13,8 @@
913
import os
1014
import subprocess
1115
import sys
12-
from typing import List
16+
import tempfile
17+
from typing import List, Optional
1318

1419

1520
def run(cmd: List[str], cwd: str) -> subprocess.CompletedProcess:
@@ -34,24 +39,114 @@ def remove_if_exists(path: str) -> None:
3439
pass
3540

3641

37-
def sha_exists(ledger_path: str, sha: str) -> bool:
42+
def sha_in_content(content: str, sha: str) -> bool:
43+
"""Check if a SHA already exists in JSONL content."""
44+
for raw in content.splitlines():
45+
line = raw.strip()
46+
if not line:
47+
continue
48+
try:
49+
obj = json.loads(line)
50+
except Exception:
51+
continue
52+
if isinstance(obj, dict) and obj.get("sha") == sha:
53+
return True
54+
return False
55+
56+
57+
def get_meta_branch_content(repo_root: str) -> Optional[str]:
58+
"""Read current ledger.jsonl from agentdiff-meta branch. Returns None if not found."""
59+
res = run(["git", "show", "agentdiff-meta:ledger.jsonl"], cwd=repo_root)
60+
if res.returncode == 0:
61+
return res.stdout
62+
return None
63+
64+
65+
def write_to_meta_branch(repo_root: str, new_content: str) -> bool:
66+
"""
67+
Write new_content as ledger.jsonl on the agentdiff-meta branch using
68+
git plumbing — no checkout, no working-tree changes.
69+
Returns True on success.
70+
"""
71+
tmp_path = None
72+
try:
73+
# Write content to a temp file so git can hash it.
74+
with tempfile.NamedTemporaryFile(
75+
mode="w", suffix=".jsonl", delete=False, encoding="utf-8"
76+
) as f:
77+
f.write(new_content)
78+
tmp_path = f.name
79+
80+
# Hash the file to get a blob SHA.
81+
blob_res = run(["git", "hash-object", "-w", tmp_path], cwd=repo_root)
82+
if blob_res.returncode != 0:
83+
return False
84+
blob_sha = blob_res.stdout.strip()
85+
86+
# Create a tree containing just ledger.jsonl.
87+
tree_input = f"100644 blob {blob_sha}\tledger.jsonl\n"
88+
tree_res = subprocess.run(
89+
["git", "mktree"],
90+
input=tree_input,
91+
text=True,
92+
capture_output=True,
93+
cwd=repo_root,
94+
)
95+
if tree_res.returncode != 0:
96+
return False
97+
tree_sha = tree_res.stdout.strip()
98+
99+
# Find parent commit if the branch already exists.
100+
parent_res = run(
101+
["git", "rev-parse", "refs/heads/agentdiff-meta"], cwd=repo_root
102+
)
103+
parent_args: List[str] = []
104+
if parent_res.returncode == 0:
105+
parent_sha = parent_res.stdout.strip()
106+
if parent_sha:
107+
parent_args = ["-p", parent_sha]
108+
109+
# Get short SHA of the current HEAD for the commit message.
110+
short_res = run(["git", "rev-parse", "--short", "HEAD"], cwd=repo_root)
111+
short_sha = short_res.stdout.strip() if short_res.returncode == 0 else "?"
112+
113+
# Create the commit object.
114+
commit_res = subprocess.run(
115+
["git", "commit-tree", tree_sha, "-m", f"agentdiff: {short_sha}"]
116+
+ parent_args,
117+
text=True,
118+
capture_output=True,
119+
cwd=repo_root,
120+
)
121+
if commit_res.returncode != 0:
122+
return False
123+
commit_sha = commit_res.stdout.strip()
124+
125+
# Update the branch ref.
126+
ref_res = run(
127+
["git", "update-ref", "refs/heads/agentdiff-meta", commit_sha],
128+
cwd=repo_root,
129+
)
130+
return ref_res.returncode == 0
131+
132+
except Exception:
133+
return False
134+
finally:
135+
if tmp_path and os.path.exists(tmp_path):
136+
try:
137+
os.unlink(tmp_path)
138+
except Exception:
139+
pass
140+
141+
142+
def sha_exists_on_disk(ledger_path: str, sha: str) -> bool:
38143
if not os.path.exists(ledger_path):
39144
return False
40145
try:
41146
with open(ledger_path, "r", encoding="utf-8") as f:
42-
for raw in f:
43-
line = raw.strip()
44-
if not line:
45-
continue
46-
try:
47-
obj = json.loads(line)
48-
except Exception:
49-
continue
50-
if isinstance(obj, dict) and obj.get("sha") == sha:
51-
return True
147+
return sha_in_content(f.read(), sha)
52148
except Exception:
53149
return False
54-
return False
55150

56151

57152
def main() -> int:
@@ -81,7 +176,14 @@ def main() -> int:
81176
if not sha:
82177
return 1
83178

84-
if sha_exists(ledger_path, sha):
179+
# Check if SHA already recorded — try meta branch first, then disk file.
180+
meta_content = get_meta_branch_content(repo_root)
181+
if meta_content is not None:
182+
if sha_in_content(meta_content, sha):
183+
remove_if_exists(pending_ledger_path)
184+
remove_if_exists(pending_context_path)
185+
return 0
186+
elif sha_exists_on_disk(ledger_path, sha):
85187
remove_if_exists(pending_ledger_path)
86188
remove_if_exists(pending_context_path)
87189
return 0
@@ -94,7 +196,7 @@ def main() -> int:
94196
author_res = run(["git", "show", "-s", "--format=%an", "HEAD"], cwd=repo_root)
95197
author = author_res.stdout.strip() if author_res.returncode == 0 else ""
96198

97-
entry = {
199+
entry: dict = {
98200
"sha": sha,
99201
"ts": ts,
100202
"agent": str(pending.get("agent") or "human"),
@@ -118,14 +220,22 @@ def main() -> int:
118220
if isinstance(pending.get("attribution"), dict):
119221
entry["attribution"] = pending["attribution"]
120222

223+
entry = {k: v for (k, v) in entry.items() if v is not None}
224+
entry_line = json.dumps(entry, separators=(",", ":")) + "\n"
225+
226+
# Try writing to agentdiff-meta branch (enterprise storage).
227+
existing = meta_content if meta_content is not None else ""
228+
if write_to_meta_branch(repo_root, existing + entry_line):
229+
remove_if_exists(pending_ledger_path)
230+
remove_if_exists(pending_context_path)
231+
return 0
232+
233+
# Fallback: append to working-tree ledger.jsonl.
121234
parent = os.path.dirname(ledger_path)
122235
if parent:
123236
os.makedirs(parent, exist_ok=True)
124-
125-
entry = {k: v for (k, v) in entry.items() if v is not None}
126-
127237
with open(ledger_path, "a", encoding="utf-8") as f:
128-
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
238+
f.write(entry_line)
129239

130240
remove_if_exists(pending_ledger_path)
131241
remove_if_exists(pending_context_path)

‎src/commands/log.rs‎

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::cli::LogArgs;
22
use crate::store::Store;
3-
use crate::util::{fmt_lines, fmt_prompt, fmt_time};
3+
use crate::util::{fmt_prompt, fmt_time};
44
use anyhow::Result;
55
use colored::Colorize;
6+
use std::collections::HashMap;
67

78
pub fn run(store: &Store, args: &LogArgs) -> Result<()> {
89
let mut entries = store.load_entries()?;
@@ -14,53 +15,98 @@ pub fn run(store: &Store, args: &LogArgs) -> Result<()> {
1415
entries.sort_by_key(|e| e.timestamp);
1516
entries.reverse(); // newest first
1617

17-
let display_entries = entries.iter().take(args.limit);
18+
// Group by commit hash, preserving newest-first order.
19+
let mut seen_commits: Vec<String> = Vec::new();
20+
let mut by_commit: HashMap<String, Vec<usize>> = HashMap::new();
21+
for (i, e) in entries.iter().enumerate() {
22+
let key = if e.commit_hash.is_empty() {
23+
"uncommitted".to_string()
24+
} else {
25+
e.commit_hash.clone()
26+
};
27+
if !by_commit.contains_key(&key) {
28+
seen_commits.push(key.clone());
29+
}
30+
by_commit.entry(key).or_default().push(i);
31+
}
32+
33+
let display_commits = seen_commits.iter().take(args.limit);
1834

1935
println!();
2036
println!(
21-
" {} — last {} entries\n",
37+
" {} — last {} commits\n",
2238
"agentdiff log".cyan().bold(),
2339
args.limit
2440
);
2541

26-
for e in display_entries {
27-
let agent_color = crate::util::agent_color_str(&e.agent);
28-
let commit = if e.commit_hash.len() > 8 {
29-
&e.commit_hash[..8]
42+
for commit_key in display_commits {
43+
let idxs = &by_commit[commit_key];
44+
let first = &entries[idxs[0]];
45+
46+
let commit_short = if commit_key == "uncommitted" {
47+
"untracked".to_string()
48+
} else if commit_key.len() > 8 {
49+
commit_key[..8].to_string()
3050
} else {
31-
&e.commit_hash
51+
commit_key.clone()
3252
};
33-
let uncommitted = if !e.committed {
53+
54+
let uncommitted = if !first.committed {
3455
" (uncommitted)".yellow()
3556
} else {
3657
"".normal()
3758
};
3859

3960
println!(
40-
" {} {} {:<12} {} → {}{}",
41-
commit.dimmed(),
42-
fmt_time(&e.timestamp.to_rfc3339()),
43-
agent_color,
44-
e.tool.dimmed(),
45-
e.file,
61+
" {} {}{}",
62+
commit_short.dimmed(),
63+
fmt_time(&first.timestamp.to_rfc3339()),
4664
uncommitted
4765
);
4866

67+
// Group files by agent within this commit.
68+
let mut agent_files: HashMap<&str, Vec<&str>> = HashMap::new();
69+
for &i in idxs {
70+
let e = &entries[i];
71+
agent_files.entry(e.agent.as_str()).or_default().push(e.file.as_str());
72+
}
73+
74+
if agent_files.len() > 1 {
75+
// Multi-agent commit — show each agent with their files.
76+
let mut agents: Vec<&str> = agent_files.keys().copied().collect();
77+
agents.sort();
78+
for agent in agents {
79+
let files = &agent_files[agent];
80+
let agent_col = crate::util::agent_color_str(agent);
81+
let file_list = if files.len() <= 3 {
82+
files.join(", ")
83+
} else {
84+
format!("{} +{} more", files[..3].join(", "), files.len() - 3)
85+
};
86+
println!(" {} {}", agent_col, file_list.dimmed());
87+
}
88+
} else {
89+
// Single agent — compact file list.
90+
let (agent, files) = agent_files.iter().next().unwrap();
91+
let agent_col = crate::util::agent_color_str(agent);
92+
let file_list = if files.len() <= 4 {
93+
files.join(", ")
94+
} else {
95+
format!("{} +{} more", files[..4].join(", "), files.len() - 4)
96+
};
97+
println!(" {} {}", agent_col, file_list.dimmed());
98+
}
99+
49100
if args.full_prompt {
50-
if let Some(ref prompt) = e.prompt {
101+
if let Some(ref prompt) = first.prompt {
51102
if !prompt.is_empty() && prompt != "unknown" {
52103
println!(" Prompt: {}", fmt_prompt(prompt, 80));
53104
}
54105
}
55106
}
56107

57-
println!(
58-
" Lines: {} | Model: {}",
59-
fmt_lines(&e.lines),
60-
e.model.dimmed()
61-
);
108+
println!();
62109
}
63110

64-
println!();
65111
Ok(())
66112
}

‎src/config.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ fn default_schema() -> String {
3939
}
4040

4141
fn default_auto_amend_ledger() -> bool {
42-
true
42+
false
4343
}
4444

4545
impl Default for Config {
@@ -50,7 +50,7 @@ impl Default for Config {
5050
scripts_dir: None,
5151
repos: Vec::new(),
5252
agent_aliases: std::collections::HashMap::new(),
53-
auto_amend_ledger: true,
53+
auto_amend_ledger: false,
5454
}
5555
}
5656
}

0 commit comments

Comments
 (0)