Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/agentdiff-consolidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,5 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
BRANCH="${{ github.head_ref }}"
PR="${{ github.event.pull_request.number }}"
agentdiff report --format markdown --post-pr-comment "$PR" || true
45 changes: 45 additions & 0 deletions .github/workflows/agentdiff-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: AgentDiff Policy Check

on:
pull_request:

permissions:
contents: read
checks: write
pull-requests: write

jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Fetch agentdiff refs
run: |
git fetch origin '+refs/agentdiff/*:refs/agentdiff/*' || true

- name: Check out PR head branch
env:
HEAD_REF: ${{ github.head_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git checkout -B "$HEAD_REF" "$HEAD_SHA"

- name: Install agentdiff
run: |
curl -fsSL https://raw.githubusercontent.com/codeprakhar25/agentdiff/main/install.sh | bash
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Check policy
run: |
agentdiff policy check --format github-annotations

- name: Post attribution comment
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: |
PR="${{ github.event.pull_request.number }}"
agentdiff report --format markdown --post-pr-comment "$PR" || true
Comment thread
greptile-apps[bot] marked this conversation as resolved.
100 changes: 87 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ git add . && git commit -m "feat: add feature"
# 5. Inspect attribution
agentdiff list
agentdiff blame src/main.rs
agentdiff stats
agentdiff report --by-file --by-model

# 6. Give local agents context before editing traced files
agentdiff context src/main.rs --json
```

That's it. From here every commit is attributed to whichever agent (or human) wrote it.
Expand All @@ -92,9 +95,11 @@ That's it. From here every commit is attributed to whichever agent (or human) wr
| `agentdiff install-ci` | Write CI workflow YAMLs to `.github/workflows/` — run once per repo |
| `agentdiff list` | List attribution entries |
| `agentdiff blame <file>` | Line-level attribution, like `git blame` |
| `agentdiff context <file>` | File-scoped trace context: intent, prompt excerpt, files read, flags, trust |
| `agentdiff diff [<sha>]` | Attribution diff for a commit or range |
| `agentdiff show <sha>` | Full details for one trace entry |
| `agentdiff report` | Aggregate report (text, markdown, annotations, JSONL) |
| `agentdiff install-skill` | Install the AgentDiff context skill into a project or globally |
| `agentdiff status` | Health check — hooks, keys, traces |
| `agentdiff status --remote` | Show remote trace ref state (`refs/agentdiff/*` on origin) |
| `agentdiff push` | Push local traces to per-branch ref on origin |
Expand All @@ -117,6 +122,10 @@ agentdiff list --limit 50
# Blame for a specific agent only
agentdiff blame src/api.rs --agent claude-code

# Show why a file was changed and what context the agent used
agentdiff context src/api.rs
agentdiff context src/api.rs --json

# Report broken down by file and model
agentdiff report --by-file --by-model

Expand All @@ -127,10 +136,18 @@ agentdiff report --since 2026-01-01T00:00:00Z
agentdiff report --format markdown --out report.md
agentdiff report --format annotations --out annotations.json

# Include intent, files read, flags, trust, and trace IDs in reports
agentdiff report --format markdown --context
agentdiff report --format json --context

# Post report as a PR comment (auto-detects PR from current branch)
agentdiff report --format markdown --post-pr-comment
agentdiff report --format markdown --post-pr-comment 42 # explicit PR number

# Install the local agent guidance skill into this repo
agentdiff install-skill --scope project
agentdiff install-skill --scope global # optional personal default

# Attribution diff for last 3 commits
agentdiff diff HEAD~3

Expand Down Expand Up @@ -219,10 +236,12 @@ agentdiff list --uncommitted
</details>

<details>
<summary>agentdiff stats</summary>
<summary>agentdiff report --by-file --by-model</summary>

```
agentdiff stats — 4,231 lines tracked
agentdiff report

Total lines tracked: 4,231

By Agent:
claude-code 2,741 (65%) ████████████████████
Expand All @@ -240,6 +259,29 @@ agentdiff list --uncommitted

</details>

<details>
<summary>agentdiff context src/api.rs --json</summary>

```json
{
"file": "src/api.rs",
"traces": [
{
"short_id": "60eb15b8",
"agent": "cursor",
"intent": "security hardening",
"prompt_excerpt": "add rate limiting to the API",
"files_read": ["src/api.rs", "src/config.rs"],
"flags": ["security"],
"trust": 92,
"ranges": [{ "start_line": 17, "end_line": 24 }]
}
]
}
```

</details>

<details>
<summary>agentdiff remote-status</summary>

Expand Down Expand Up @@ -287,23 +329,32 @@ agentdiff list --uncommitted
</details>

<details>
<summary>agentdiff report (Markdown)</summary>
<summary>agentdiff report --format markdown --context</summary>

```markdown
## AI Attribution Report
# AgentDiff Report

**Total lines tracked:** 4,231 across 47 commits
## Summary

| Agent | Lines | Share |
|-------|-------|-------|
| Agent | Lines | % |
|-------|-------|---|
| claude-code | 2,741 | 65% |
| cursor | 973 | 23% |
| copilot | 353 | 8% |
| human | 164 | 4% |

### Recent AI commits
- `a1b2c3d` claude-code — "add auth middleware" → src/auth.rs (17-24)
- `b2c3d4e` cursor — "refactor utils" → src/utils.rs (1-89)
## Review Context

- Intent: security hardening (17 lines, 1 file)
- Agent/model: claude-code / claude-sonnet-4-6
- Files read: src/api.rs, src/config.rs
- Prompt: add rate limiting to the API
- Flags: security

## Files To Review First

| File | Lines | Dominant Agent | Intent | Context |
|------|-------|----------------|--------|---------|
| src/api.rs | 17 | claude-code | security hardening | trace 550e8400 |
```

</details>
Expand Down Expand Up @@ -352,7 +403,7 @@ When an AI agent makes an edit, its hook fires and writes a JSON entry to `<repo

On `git commit`:
- Pre-commit hook: matches session entries against staged diff → writes `pending-ledger.json`
- Post-commit hook: finalizes one trace entry (UUID-keyed, Agent Trace v0.1 format) into the local buffer at `.git/agentdiff/traces/{branch}.jsonl`; signs it with ed25519 if keys are configured
- Post-commit hook: finalizes one trace entry (UUID-keyed, Agent Trace v0.1 format) into the local buffer at `.git/agentdiff/traces/{branch}.jsonl`; attaches structured context such as `intent`, `files_read`, `flags`, and `trust`; signs it with ed25519 if keys are configured

On `git push`:
- Pre-push hook: uploads the local trace buffer to `refs/agentdiff/traces/{branch}` on origin via the GitHub Git Database API; auto-consolidates on direct pushes to main/master
Expand Down Expand Up @@ -401,6 +452,28 @@ Signing keys are registered per-developer in `refs/agentdiff/keys/{key_id}:pub.k

---

## Agent Context Workflow

agentdiff can preserve lightweight intent and files-read context so reviewers and local agents can understand why a change was made, not just which lines were attributed.

```bash
# Before editing a traced file, inspect its local context
agentdiff context src/api.rs --json

# Before PR review or summaries, generate a context-aware report
agentdiff report --format markdown --context
agentdiff report --format json --context

# Install project-local guidance so Cursor agents learn this workflow
agentdiff install-skill --scope project
```

`agentdiff install-skill --scope project` writes `.cursor/skills/agentdiff-context/SKILL.md` in the current repo. Use `--scope global` for a personal default, and `--force` to overwrite an existing skill file.

When used with `--post-pr-comment`, context reports are filtered to commits on the current PR branch and update the existing AgentDiff comment when possible.

---

## Signing & Verification

agentdiff can sign each trace entry with an ed25519 key so tampering is detectable:
Expand Down Expand Up @@ -538,6 +611,7 @@ agentdiff config show
Each AI-assisted edit generates a trace entry containing:
- Agent name and model (e.g., `claude-code`, `claude-sonnet-4-6`)
- A short prompt excerpt (the first ~100 characters of your request to the AI)
- Optional structured context from MCP or `record-context.py`: intent, files read, flags, and trust score
- File paths and line ranges affected
- Timestamp and session ID

Expand Down
110 changes: 100 additions & 10 deletions scripts/capture-cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ def normalize_path(path: str, cwd: str = "") -> str:

p = p.replace("\\", "/")
p = re.sub(r"/{2,}", "/", p)

# Strip Windows WSL UNC prefix: /wsl.localhost/<distro>/... or /wsl$/<distro>/...
# These arrive after \\ → / conversion above.
wsl_match = re.match(r"^/wsl(?:\.localhost|[$])/[^/]+(/.+)", p, re.IGNORECASE)
if wsl_match:
p = wsl_match.group(1)

# Convert Windows drive letter paths: C:/... → /mnt/c/...
drive_match = re.match(r"^([A-Za-z]):/(.*)", p)
if drive_match:
p = f"/mnt/{drive_match.group(1).lower()}/{drive_match.group(2)}"

p = os.path.expanduser(p)

if os.path.isabs(p):
Expand Down Expand Up @@ -203,20 +215,98 @@ def _cursor_project_slug(repo_root: str) -> str:
return repo_root.lstrip("/").replace("/", "-")


def _wsl_distro_name() -> str:
"""Return the WSL distro name (e.g. 'Ubuntu'), or empty string if not in WSL."""
name = os.environ.get("WSL_DISTRO_NAME", "")
if name:
return name
try:
with open("/proc/version") as f:
if "microsoft" in f.read().lower():
pass # fall through to os-release
except Exception:
return ""
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("NAME="):
return line.split("=", 1)[1].strip().strip('"').replace(" ", "-")
except Exception:
pass
return "Ubuntu"


def _cursor_transcript_candidates(conversation_id: str, repo_root: str) -> list:
"""Return candidate transcript paths to try, most-specific first."""
linux_slug = _cursor_project_slug(repo_root)
path_suffix = linux_slug # e.g. home-prakh-agentdiff

candidates = []

# Windows-side cursor projects dir (WSL2 host).
# Use the Linux username to find the matching Windows user directory — this is
# reliable on personal machines and avoids reading a different user's transcripts
# on shared Windows boxes (Administrator, Default, etc. would appear first if we
# just sorted alphabetically).
win_projects = None
try:
win_users = "/mnt/c/Users"
linux_user = os.environ.get("USER", "")
# Prefer exact username match; fall back to first dir that has .cursor/projects.
candidates_win = []
if linux_user:
exact = os.path.join(win_users, linux_user, ".cursor", "projects")
if os.path.isdir(exact):
candidates_win.append(exact)
if not candidates_win:
for entry in sorted(os.scandir(win_users), key=lambda e: e.name):
p = os.path.join(entry.path, ".cursor", "projects")
if os.path.isdir(p):
candidates_win.append(p)
break
if candidates_win:
win_projects = candidates_win[0]
except Exception:
pass

# Linux-side cursor projects dir
linux_projects = os.path.expanduser("~/.cursor/projects")

for projects_dir in filter(None, [win_projects, linux_projects if os.path.isdir(linux_projects) else None]):
# Search for a slug ending in the right path suffix (handles wsl-localhost-Ubuntu-... prefix)
try:
for slug in os.listdir(projects_dir):
if slug == linux_slug or slug.lower().endswith("-" + path_suffix.lower()):
t = os.path.join(projects_dir, slug, "agent-transcripts",
conversation_id, f"{conversation_id}.jsonl")
if os.path.exists(t):
candidates.append(t)
except Exception:
pass

# Fallback: original linux-side path
distro = _wsl_distro_name()
for slug in ([f"wsl-localhost-{distro}-{path_suffix}", linux_slug] if distro else [linux_slug]):
t = os.path.expanduser(
f"~/.cursor/projects/{slug}/agent-transcripts/{conversation_id}/{conversation_id}.jsonl"
)
if t not in candidates:
candidates.append(t)

return candidates


def get_prompt_from_transcript(conversation_id: str, repo_root: str) -> str:
"""Read the user's prompt from Cursor's agent-transcript JSONL.

Files live at:
~/.cursor/projects/{slug}/agent-transcripts/{conv_id}/{conv_id}.jsonl

We read the first user message and extract its text content.
Searches both the Linux-side and Windows-side cursor project dirs, and
tries multiple slug patterns (bare Linux slug + WSL UNC slug) so it works
regardless of how the workspace was opened.
"""
slug = _cursor_project_slug(repo_root)
transcript_path = os.path.expanduser(
f"~/.cursor/projects/{slug}/agent-transcripts/{conversation_id}/{conversation_id}.jsonl"
)
if not os.path.exists(transcript_path):
debug_log(f"transcript not found: {transcript_path}")
transcript_paths = _cursor_transcript_candidates(conversation_id, repo_root)
transcript_path = next((p for p in transcript_paths if os.path.exists(p)), None)
if not transcript_path:
debug_log(f"transcript not found for conv={conversation_id} candidates={transcript_paths[:3]}")
return ""
try:
with open(transcript_path, encoding="utf-8", errors="replace") as f:
Expand Down
11 changes: 9 additions & 2 deletions src/configure/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use std::fs;

pub fn step_configure_cursor(config: &Config) -> Result<()> {
let capture_script = config.scripts_root().join("capture-cursor.py");
let capture_cmd = format!("python3 {}", capture_script.display());
// Linux-native Cursor uses plain python3; Windows Cursor (WSL2) must prefix with `wsl`
// so the hook runs inside WSL where the Linux path is valid.
let linux_cmd = format!("python3 {}", capture_script.display());
let wsl_cmd = format!("wsl python3 {}", capture_script.display());

// Cursor on WSL2 is a Windows app — it reads hooks from the Windows-side ~/.cursor/.
// We write to both locations so native Linux installs and WSL2 are both covered.
Expand Down Expand Up @@ -48,8 +51,12 @@ pub fn step_configure_cursor(config: &Config) -> Result<()> {
continue;
}
any_found = true;
// Use wsl-prefixed command for Windows-side paths (/mnt/...) so the hook
// executes inside WSL where the Linux script path is valid.
let is_windows_side = cursor_dir.starts_with("/mnt/");
let cmd = if is_windows_side { &wsl_cmd } else { &linux_cmd };
let hooks_path = cursor_dir.join("hooks.json");
configure_cursor_hooks_file(&hooks_path, &capture_cmd)
configure_cursor_hooks_file(&hooks_path, cmd)
.with_context(|| format!("configuring {}", hooks_path.display()))?;
}

Expand Down
Loading