diff --git a/.github/workflows/agentdiff-consolidate.yml b/.github/workflows/agentdiff-consolidate.yml index 304ab5c..00d4f33 100644 --- a/.github/workflows/agentdiff-consolidate.yml +++ b/.github/workflows/agentdiff-consolidate.yml @@ -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 diff --git a/.github/workflows/agentdiff-policy.yml b/.github/workflows/agentdiff-policy.yml new file mode 100644 index 0000000..ad03b39 --- /dev/null +++ b/.github/workflows/agentdiff-policy.yml @@ -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 diff --git a/README.md b/README.md index ccc3bcf..760ff7f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ` | Line-level attribution, like `git blame` | +| `agentdiff context ` | File-scoped trace context: intent, prompt excerpt, files read, flags, trust | | `agentdiff diff []` | Attribution diff for a commit or range | | `agentdiff show ` | 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 | @@ -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 @@ -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 @@ -219,10 +236,12 @@ agentdiff list --uncommitted
-agentdiff stats +agentdiff report --by-file --by-model ``` - agentdiff stats — 4,231 lines tracked + agentdiff report + + Total lines tracked: 4,231 By Agent: claude-code 2,741 (65%) ████████████████████ @@ -240,6 +259,29 @@ agentdiff list --uncommitted
+
+agentdiff context src/api.rs --json + +```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 }] + } + ] +} +``` + +
+
agentdiff remote-status @@ -287,23 +329,32 @@ agentdiff list --uncommitted
-agentdiff report (Markdown) +agentdiff report --format markdown --context ```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 | ```
@@ -352,7 +403,7 @@ When an AI agent makes an edit, its hook fires and writes a JSON entry to ` str: p = p.replace("\\", "/") p = re.sub(r"/{2,}", "/", p) + + # Strip Windows WSL UNC prefix: /wsl.localhost//... or /wsl$//... + # 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): @@ -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: diff --git a/src/configure/cursor.rs b/src/configure/cursor.rs index 8094757..348eb8f 100644 --- a/src/configure/cursor.rs +++ b/src/configure/cursor.rs @@ -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. @@ -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()))?; }