From 7805c89e45840d9c9b387ca2423125b4b62b9c9a Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Thu, 7 May 2026 12:34:20 +0000 Subject: [PATCH 1/2] feat: inject AgentDiff context into AGENTS.md during configure (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `agentdiff configure` to write/update an `## AgentDiff` managed block in `AGENTS.md` at the current directory (repo root). This surfaces agentdiff context to agents like Codex, Cursor, and Copilot that read AGENTS.md for repo-level instructions — a gap the Claude-specific SKILL template couldn't fill. - New `src/configure/agents_md.rs`: idempotent managed-block writer using HTML comment markers, following the same pattern as antigravity GEMINI.md - `agentdiff configure` calls step_configure_agents_md(repo_root) as step 10 - `--no-agents-md` flag skips the AGENTS.md step - 4 unit tests: create-from-scratch, idempotency, in-place update, and append-to-existing-content - README updated to document AGENTS.md support and --no-agents-md flag Closes #15 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 +- src/cli.rs | 4 + src/configure/agents_md.rs | 195 +++++++++++++++++++++++++++++++++++++ src/configure/mod.rs | 11 ++- src/main.rs | 2 +- 5 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/configure/agents_md.rs diff --git a/README.md b/README.md index ac7167a..a01b606 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,15 @@ That's it. From here every commit is attributed to whichever agent (or human) wr > **Note:** `agentdiff configure` installs capture scripts globally, but capture only fires in repos where `agentdiff init` has been run (the `.git/agentdiff/` directory must exist). Running `configure` on its own does not track any repo — you must also run `agentdiff init` inside each repo you want to track. +> **AGENTS.md:** `agentdiff configure` also writes (or updates) an `## AgentDiff` section in `AGENTS.md` in the current directory. This file is the emerging standard for multi-agent repo context — Codex, Cursor, Copilot, and other tools read it to understand repo conventions. The section is idempotent: re-running configure updates it without duplicating or touching the rest of your `AGENTS.md`. Use `--no-agents-md` to skip. + --- ## Commands | Command | Description | |---------|-------------| -| `agentdiff configure` | Install global agent capture hooks — run once per machine | +| `agentdiff configure` | Install global agent capture hooks and write `AGENTS.md` context — run once per machine | | `agentdiff init` | Initialize tracking in current repository (required per repo) | | `agentdiff install-ci` | Write CI workflow YAMLs to `.github/workflows/` — run once per repo | | `agentdiff list` | List attribution entries | diff --git a/src/cli.rs b/src/cli.rs index 3978da4..dc82c5c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,10 @@ pub struct ConfigureArgs { /// Skip MCP server registration with Claude Code #[arg(long)] pub no_mcp: bool, + + /// Skip writing/updating the AgentDiff section in AGENTS.md + #[arg(long)] + pub no_agents_md: bool, } #[derive(Args, Debug)] diff --git a/src/configure/agents_md.rs b/src/configure/agents_md.rs new file mode 100644 index 0000000..c8c6d38 --- /dev/null +++ b/src/configure/agents_md.rs @@ -0,0 +1,195 @@ +use crate::util::{dim, ok}; +use anyhow::Result; +use std::path::Path; + +const AGENTS_MD_START: &str = ""; +const AGENTS_MD_END: &str = ""; + +fn managed_block() -> String { + format!( + "{start}\n\ + ## AgentDiff\n\ + \n\ + [AgentDiff](https://github.com/codeprakhar25/agentdiff) tracks which AI agent \ + wrote which lines of code in this repository. Every file edit made through a \ + configured agent is captured and stored as a signed `AgentTrace` record in \ + `.git/agentdiff/traces/`. Attribution is computed per-commit and covers all \ + configured agents: Claude Code, Cursor, Codex, Copilot, Windsurf, OpenCode, \ + and Gemini.\n\ + \n\ + ### Before editing traced files\n\ + \n\ + Check attribution context to understand prior AI contributions:\n\ + \n\ + ```bash\n\ + agentdiff context path/to/file\n\ + agentdiff context path/to/file --json\n\ + ```\n\ + \n\ + ### Before committing\n\ + \n\ + Let the git hooks run — do **not** bypass them with `--no-verify`. The \ + `pre-commit` hook computes per-file attribution, and the `post-commit` hook \ + signs and stores the trace. Skipping either breaks the attribution ledger.\n\ + \n\ + ### When reviewing PRs\n\ + \n\ + ```bash\n\ + agentdiff report --format markdown # Aggregate attribution summary\n\ + agentdiff blame src/main.rs # Line-level attribution\n\ + agentdiff diff HEAD # Attribution changes in a commit\n\ + ```\n\ + \n\ + ### Attribution conventions\n\ + \n\ + - Files you edit without an AI agent are attributed to `human`\n\ + - Files changed by an agent use its name: `claude-code`, `cursor`, `codex`, etc.\n\ + - Copilot inline completions are tracked for stats but excluded from file attribution\n\ + - When multiple agents touch a file in one session, the majority-lines agent wins\n\ + {end}", + start = AGENTS_MD_START, + end = AGENTS_MD_END, + ) +} + +pub fn step_configure_agents_md(repo_root: &Path) -> Result<()> { + let agents_md_path = repo_root.join("AGENTS.md"); + let block = managed_block(); + + let existing = std::fs::read_to_string(&agents_md_path).unwrap_or_default(); + + if let Some(start_pos) = existing.find(AGENTS_MD_START) { + if let Some(rel_end) = existing[start_pos..].find(AGENTS_MD_END) { + let end_pos = start_pos + rel_end + AGENTS_MD_END.len(); + let current_block = &existing[start_pos..end_pos]; + if current_block == block { + println!("{} AGENTS.md AgentDiff section already up-to-date", dim()); + return Ok(()); + } + // Update existing block in place. + let updated = format!("{}{}{}", &existing[..start_pos], block, &existing[end_pos..]); + std::fs::write(&agents_md_path, updated)?; + println!( + "{} AGENTS.md AgentDiff section updated in {}", + ok(), + agents_md_path.display() + ); + return Ok(()); + } + } + + // Append block to file (create if absent). + let separator = if existing.is_empty() || existing.ends_with('\n') { + "\n" + } else { + "\n\n" + }; + let updated = format!("{}{}{}\n", existing, separator, block); + std::fs::write(&agents_md_path, updated)?; + + if existing.is_empty() { + println!( + "{} AGENTS.md created with AgentDiff section at {}", + ok(), + agents_md_path.display() + ); + } else { + println!( + "{} AGENTS.md AgentDiff section added to {}", + ok(), + agents_md_path.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmp_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir() + .join(format!("agentdiff-agents-md-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn creates_agents_md_from_scratch() { + let dir = tmp_dir().join("scratch"); + fs::create_dir_all(&dir).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + + let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + assert!(content.contains(AGENTS_MD_START)); + assert!(content.contains(AGENTS_MD_END)); + assert!(content.contains("## AgentDiff")); + assert!(content.contains("agentdiff context path/to/file")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn idempotent_when_block_unchanged() { + let dir = tmp_dir().join("idempotent"); + fs::create_dir_all(&dir).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + let first = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + let second = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + + assert_eq!(first, second); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn updates_existing_block_without_duplicating() { + let dir = tmp_dir().join("update"); + fs::create_dir_all(&dir).unwrap(); + + // Write a stale block. + let stale = format!( + "# My Project\n\n{start}\n## AgentDiff\n\nOld content here.\n{end}\n\n## Other section\n", + start = AGENTS_MD_START, + end = AGENTS_MD_END, + ); + fs::write(dir.join("AGENTS.md"), &stale).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + + let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + // Should have exactly one managed block. + assert_eq!(content.matches(AGENTS_MD_START).count(), 1); + assert_eq!(content.matches(AGENTS_MD_END).count(), 1); + // Old content replaced. + assert!(!content.contains("Old content here.")); + // Surrounding content preserved. + assert!(content.contains("# My Project")); + assert!(content.contains("## Other section")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn appends_to_existing_agents_md_without_managed_block() { + let dir = tmp_dir().join("append"); + fs::create_dir_all(&dir).unwrap(); + + let pre_existing = "# My Project\n\nSome existing content.\n"; + fs::write(dir.join("AGENTS.md"), pre_existing).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + + let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + assert!(content.starts_with("# My Project")); + assert!(content.contains("Some existing content.")); + assert!(content.contains(AGENTS_MD_START)); + assert!(content.contains("## AgentDiff")); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/configure/mod.rs b/src/configure/mod.rs index d903173..06ccab6 100644 --- a/src/configure/mod.rs +++ b/src/configure/mod.rs @@ -1,3 +1,4 @@ +mod agents_md; mod antigravity; mod claude; mod codex; @@ -12,7 +13,7 @@ use crate::util::{dim, ok, warn}; use anyhow::{Context, Result}; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, MultiSelect}; -use std::{fs, io::IsTerminal, path::Path, process::Command}; +use std::{fs, io::IsTerminal, path::Path, path::PathBuf, process::Command}; // Script sources embedded at compile time. const CLAUDE_CAPTURE_SCRIPT: &str = include_str!("../../scripts/capture-claude.py"); @@ -28,7 +29,7 @@ const RECORD_CONTEXT_SCRIPT: &str = include_str!("../../scripts/record-context.p const WRITE_NOTE_SCRIPT: &str = include_str!("../../scripts/write-note.py"); /// Configure global agent hooks — run once per machine, no git repo required. -pub fn run_configure(config: &mut Config, args: &ConfigureArgs) -> Result<()> { +pub fn run_configure(config: &mut Config, args: &ConfigureArgs, repo_root: &PathBuf) -> Result<()> { println!("{}", "agentdiff configure".bold().cyan()); println!(); println!( @@ -86,6 +87,11 @@ pub fn run_configure(config: &mut Config, args: &ConfigureArgs) -> Result<()> { copilot::step_configure_copilot(config)?; } + // Step 10 — write/update AgentDiff section in AGENTS.md (repo root, if available) + if !args.no_agents_md { + agents_md::step_configure_agents_md(repo_root)?; + } + // Save updated config config.save()?; println!( @@ -712,6 +718,7 @@ mod tests { no_opencode: false, no_copilot: false, no_mcp: false, + no_agents_md: false, } } diff --git a/src/main.rs b/src/main.rs index 87489db..fffd73d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,7 @@ fn main() -> anyhow::Result<()> { match cli.command { Command::Configure(args) => { let mut cfg = config; - configure::run_configure(&mut cfg, &args) + configure::run_configure(&mut cfg, &args, &repo_root) } Command::Init(args) => { let mut cfg = config; From 5bad25434d032d20f859b896f88866cec66e0c79 Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Mon, 11 May 2026 12:30:06 +0000 Subject: [PATCH 2/2] fix: skip AGENTS.md outside git repos; warn on unclosed sentinel Two bugs flagged in review: - configure run outside a git repo (machine-global setup) wrote AGENTS.md to current_dir() instead of skipping. Now checks for .git before writing. - File with start sentinel but no end sentinel silently got a second block appended. Now warns and skips to avoid corrupting the file. Also: &PathBuf -> &Path in run_configure signature (idiomatic). Added two tests: skips_when_not_a_git_repo, warns_and_skips_on_unclosed_sentinel. Co-Authored-By: Claude Sonnet 4.6 --- src/configure/agents_md.rs | 59 ++++++++++++++++++++++++++++++++++++++ src/configure/mod.rs | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/configure/agents_md.rs b/src/configure/agents_md.rs index c8c6d38..8e732df 100644 --- a/src/configure/agents_md.rs +++ b/src/configure/agents_md.rs @@ -53,6 +53,17 @@ fn managed_block() -> String { } pub fn step_configure_agents_md(repo_root: &Path) -> Result<()> { + // AGENTS.md is a per-project file. Skip when not inside a git repository + // (e.g. `agentdiff configure` run from a home directory for machine-global setup). + if !repo_root.join(".git").exists() { + println!( + "{} AGENTS.md skipped (not a git repository: {})", + dim(), + repo_root.display() + ); + return Ok(()); + } + let agents_md_path = repo_root.join("AGENTS.md"); let block = managed_block(); @@ -76,6 +87,15 @@ pub fn step_configure_agents_md(repo_root: &Path) -> Result<()> { ); return Ok(()); } + // Start sentinel present but no end sentinel — file is malformed. Warn and skip + // rather than appending a second block and corrupting the file further. + eprintln!( + "warn: AGENTS.md has an opening agentdiff sentinel but no closing sentinel; \ + skipping to avoid creating a duplicate block. Fix manually: add \ + `{}` after the managed section.", + AGENTS_MD_END + ); + return Ok(()); } // Append block to file (create if absent). @@ -115,10 +135,16 @@ mod tests { dir } + /// Create a fake git repo root (just a `.git` dir) so the git-repo guard passes. + fn fake_git_repo(dir: &std::path::Path) { + fs::create_dir_all(dir.join(".git")).unwrap(); + } + #[test] fn creates_agents_md_from_scratch() { let dir = tmp_dir().join("scratch"); fs::create_dir_all(&dir).unwrap(); + fake_git_repo(&dir); step_configure_agents_md(&dir).unwrap(); @@ -135,6 +161,7 @@ mod tests { fn idempotent_when_block_unchanged() { let dir = tmp_dir().join("idempotent"); fs::create_dir_all(&dir).unwrap(); + fake_git_repo(&dir); step_configure_agents_md(&dir).unwrap(); let first = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); @@ -150,6 +177,7 @@ mod tests { fn updates_existing_block_without_duplicating() { let dir = tmp_dir().join("update"); fs::create_dir_all(&dir).unwrap(); + fake_git_repo(&dir); // Write a stale block. let stale = format!( @@ -178,6 +206,7 @@ mod tests { fn appends_to_existing_agents_md_without_managed_block() { let dir = tmp_dir().join("append"); fs::create_dir_all(&dir).unwrap(); + fake_git_repo(&dir); let pre_existing = "# My Project\n\nSome existing content.\n"; fs::write(dir.join("AGENTS.md"), pre_existing).unwrap(); @@ -192,4 +221,34 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + + #[test] + fn skips_when_not_a_git_repo() { + let dir = tmp_dir().join("not-git"); + fs::create_dir_all(&dir).unwrap(); + // No .git directory — should skip without error. + step_configure_agents_md(&dir).unwrap(); + assert!(!dir.join("AGENTS.md").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn warns_and_skips_on_unclosed_sentinel() { + let dir = tmp_dir().join("malformed"); + fs::create_dir_all(&dir).unwrap(); + fake_git_repo(&dir); + + // Write a file with an opening sentinel but no closing sentinel. + let malformed = format!("# Project\n\n{start}\n## AgentDiff\n\nTruncated block with no end.\n", start = AGENTS_MD_START); + fs::write(dir.join("AGENTS.md"), &malformed).unwrap(); + + step_configure_agents_md(&dir).unwrap(); + + // File must be unchanged — no duplicate block appended. + let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap(); + assert_eq!(content.matches(AGENTS_MD_START).count(), 1, "must not duplicate start sentinel"); + assert!(!content.contains(AGENTS_MD_END), "end sentinel must not be added silently"); + + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/configure/mod.rs b/src/configure/mod.rs index 06ccab6..e185997 100644 --- a/src/configure/mod.rs +++ b/src/configure/mod.rs @@ -29,7 +29,7 @@ const RECORD_CONTEXT_SCRIPT: &str = include_str!("../../scripts/record-context.p const WRITE_NOTE_SCRIPT: &str = include_str!("../../scripts/write-note.py"); /// Configure global agent hooks — run once per machine, no git repo required. -pub fn run_configure(config: &mut Config, args: &ConfigureArgs, repo_root: &PathBuf) -> Result<()> { +pub fn run_configure(config: &mut Config, args: &ConfigureArgs, repo_root: &Path) -> Result<()> { println!("{}", "agentdiff configure".bold().cyan()); println!(); println!(