Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agentdiff"
version = "0.1.25"
version = "0.1.26"
edition = "2024"
rust-version = "1.85"
description = "Audit and trace autonomous AI code contributions in git repositories"
Expand Down
8 changes: 8 additions & 0 deletions scripts/finalize-ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ def write_agent_trace(repo_root: str, pending: dict, sha: str, ts: str) -> Optio
metadata["flags"] = pending["flags"]
if pending.get("session_id"):
metadata["session_id"] = str(pending["session_id"])
if pending.get("intent"):
metadata["intent"] = str(pending["intent"])
if isinstance(pending.get("files_read"), list) and pending["files_read"]:
metadata["files_read"] = [str(p) for p in pending["files_read"]]
if git_author:
metadata["author"] = git_author
if pending.get("tool"):
metadata["capture_tool"] = str(pending["tool"])

trace: dict = {
"version": "0.1.0",
Expand Down
15 changes: 14 additions & 1 deletion scripts/record-context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import argparse
import json
import os
import select
import subprocess
import sys
from datetime import datetime, timezone
Expand Down Expand Up @@ -42,6 +43,18 @@ def parse_json_array(raw: str):
return []


def read_available_stdin() -> str:
if sys.stdin.isatty():
return ""
try:
ready, _, _ = select.select([sys.stdin], [], [], 0)
except Exception:
return ""
if not ready:
return ""
return sys.stdin.read()


def main() -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("--cwd", default=os.getcwd())
Expand All @@ -56,7 +69,7 @@ def main() -> int:
args = parser.parse_args()

payload = {}
stdin = sys.stdin.read()
stdin = read_available_stdin()
if stdin.strip():
try:
obj = json.loads(stdin)
Expand Down
47 changes: 47 additions & 0 deletions scripts/tests/test_capture_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
Covers both capture-claude.py (capture side) and finalize-ledger.py (trace side).
"""
import importlib.util
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
Expand Down Expand Up @@ -161,6 +163,51 @@ def test_returns_true_when_enabled(self):
if original is not None:
os.environ["HOME"] = original

def test_write_agent_trace_persists_structured_context_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp) / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
(repo / "README.md").write_text("test\n", encoding="utf-8")
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)

pending = {
"agent": "cursor",
"git_author": "Prakhar",
"model": "cursor-test",
"session_id": "sess-1",
"lines": {"src/app.py": [[1, 2]]},
"prompt_excerpt": "add route guard",
"prompt_hash": "abc123",
"intent": "security hardening",
"files_read": ["src/auth.py", "src/config.py"],
"trust": 91,
"flags": ["security"],
"tool": "afterFileEdit",
}

original = os.environ.get("HOME")
try:
os.environ["HOME"] = tmp
traces_path = self.mod.write_agent_trace(
str(repo), pending, "deadbeef", "2026-04-27T00:00:00Z"
)
finally:
if original is not None:
os.environ["HOME"] = original

self.assertIsNotNone(traces_path)
raw = Path(traces_path).read_text(encoding="utf-8").strip()
trace = json.loads(raw)
metadata = trace["metadata"]["agentdiff"]
self.assertEqual(metadata["intent"], "security hardening")
self.assertEqual(metadata["files_read"], ["src/auth.py", "src/config.py"])
self.assertEqual(metadata["author"], "Prakhar")
self.assertEqual(metadata["capture_tool"], "afterFileEdit")


if __name__ == "__main__":
unittest.main()
47 changes: 47 additions & 0 deletions scripts/tests/test_record_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import json
import subprocess
import tempfile
import unittest
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
RECORD_CONTEXT = REPO_ROOT / "scripts" / "record-context.py"


class RecordContextTests(unittest.TestCase):
def test_cli_flags_do_not_block_without_stdin_payload(self):
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp) / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=repo, check=True, capture_output=True)

result = subprocess.run(
[
"python3",
str(RECORD_CONTEXT),
"--cwd",
str(repo),
"--agent",
"cursor",
"--model-id",
"validation-model",
"--files-read",
'["src/app.py"]',
"--intent",
"context validation",
],
text=True,
capture_output=True,
timeout=2,
)

self.assertEqual(result.returncode, 0)
pending = json.loads((repo / ".git" / "agentdiff" / "pending.json").read_text())
self.assertEqual(pending["agent"], "cursor")
self.assertEqual(pending["model_id"], "validation-model")
self.assertEqual(pending["files_read"], ["src/app.py"])
self.assertEqual(pending["intent"], "context validation")


if __name__ == "__main__":
unittest.main()
48 changes: 47 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ pub enum Command {
/// Show line-level attribution for a file (like git-blame)
Blame(BlameArgs),

/// Show agent context for a file
Context(ContextArgs),

/// Aggregate report in text, markdown, annotations, or JSONL format
Report(ReportArgs),

Expand Down Expand Up @@ -63,6 +66,9 @@ pub enum Command {
/// Write agentdiff CI workflow files to .github/workflows/
InstallCi(InstallCiArgs),

/// Install the AgentDiff context skill for Cursor agents
InstallSkill(InstallSkillArgs),

/// [internal] Sign the last trace entry — called by the post-commit hook
#[command(hide = true)]
SignEntry,
Expand Down Expand Up @@ -158,9 +164,27 @@ pub struct BlameArgs {
pub agent: Option<String>,
}

#[derive(Args, Debug)]
pub struct ContextArgs {
/// File to explain (relative to repo root)
pub file: std::path::PathBuf,

/// Output machine-readable JSON
#[arg(long)]
pub json: bool,

/// Only include traces whose agent name contains this substring
#[arg(long)]
pub agent: Option<String>,

/// Limit number of trace records shown
#[arg(short = 'n', long, default_value_t = 10)]
pub limit: usize,
}

#[derive(Args, Debug)]
pub struct ReportArgs {
/// Output format: text (default, terminal-friendly) | markdown | annotations | jsonl
/// Output format: text (default, terminal-friendly) | markdown | annotations | jsonl | json
#[arg(long, default_value = "text")]
pub format: ReportFormat,

Expand All @@ -185,6 +209,10 @@ pub struct ReportArgs {
#[arg(long)]
pub model: Option<String>,

/// Include structured intent/files-read context in markdown reports (JSON is always structured)
#[arg(long)]
pub context: bool,

/// With --format=text: also show per-file breakdown
#[arg(long)]
pub by_file: bool,
Expand Down Expand Up @@ -258,6 +286,8 @@ pub enum ReportFormat {
Annotations,
/// Agent Trace JSONL (replaces `export`)
Jsonl,
/// Structured JSON summary
Json,
}

// ── Keys ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -331,3 +361,19 @@ pub struct InstallCiArgs {
pub force: bool,
}

#[derive(Args, Debug)]
pub struct InstallSkillArgs {
/// Where to install the skill: project writes .cursor/skills, global writes ~/.agents/skills
#[arg(long, default_value = "project")]
pub scope: SkillScope,

/// Overwrite an existing skill file
#[arg(long)]
pub force: bool,
}

#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)]
pub enum SkillScope {
Project,
Global,
}
Loading
Loading