agent-trace 0.1.0

Git-backed document memory, trace continuity, and permissioned writes for agent workflows
Documentation
use crate::git_store::{CommitInfo, GitStore};
use crate::manifest::Manifest;
use crate::types::{Action, Actor, DocType};
use anyhow::Result;
use std::path::Path;

/// Generate the AGENT-TRACE.md content without writing to disk.
pub fn generate(_store_root: &Path, manifest: &Manifest) -> String {
    let plans = manifest.list(Some(&DocType::Plan));
    let contexts = manifest.list(Some(&DocType::Context));
    let logs = manifest.list(Some(&DocType::Log));
    let references = manifest.list(Some(&DocType::Reference));
    let scratches = manifest.list(Some(&DocType::Scratch));

    let total = manifest.len();

    let mut out = String::from("# AGENT-TRACE.md — Agent Discovery Index\n\n");
    out.push_str(
        "> This file is auto-generated by `agent-trace`. Do not edit manually.\n\
         > Read this file to understand the document store and its contents.\n\n",
    );

    out.push_str("## How to Use This Store\n\n");
    out.push_str("- **Read** any document freely — all documents are readable by all actors.\n");
    out.push_str(
        "- **Write** only to `plan` and `scratch` documents — other types are protected.\n",
    );
    out.push_str(
        "- **Context** (`context.md`) is system-synthesized — read it for project state.\n",
    );
    out.push_str(
        "- **Running Summary** (`running_summary.md`) is incrementally updated — read it to resume work.\n",
    );
    out.push_str("- **Logs** are system-generated — do not modify them.\n");
    out.push_str("- **Reference** documents are user-curated — agents cannot modify them.\n\n");

    out.push_str("## Write Permission Rules\n\n");
    out.push_str("| Type | Agent Can Write | User Can Write | System Can Write |\n");
    out.push_str("|------|:-:|:-:|:-:|\n");
    out.push_str("| plan | ✓ | ✓ | ✗ |\n");
    out.push_str("| context | ✗ | ⚠ | ✓ |\n");
    out.push_str("| log | ✗ | ⚠ | ✓ |\n");
    out.push_str("| reference | ✗ | ✓ | ✗ |\n");
    out.push_str("| scratch | ✓ | ✓ | ✓ |\n\n");
    out.push_str("*⚠ = requires user confirmation. Unauthorized agent writes are automatically reverted.*\n\n");

    out.push_str(&format!("## Documents ({total} total)\n\n"));

    if !plans.is_empty() {
        out.push_str("### Plans\n\n");
        for p in &plans {
            let desc = if p.description.is_empty() {
                ""
            } else {
                &p.description
            };
            out.push_str(&format!("- `{}` {}\n", p.path.display(), desc));
        }
        out.push('\n');
    }

    if !contexts.is_empty() {
        out.push_str("### Context\n\n");
        for c in &contexts {
            out.push_str(&format!("- `{}`\n", c.path.display()));
        }
        out.push('\n');
    }

    if !references.is_empty() {
        out.push_str("### Reference\n\n");
        for r in &references {
            let desc = if r.description.is_empty() {
                ""
            } else {
                &r.description
            };
            out.push_str(&format!("- `{}` {}\n", r.path.display(), desc));
        }
        out.push('\n');
    }

    if !logs.is_empty() {
        out.push_str("### Logs\n\n");
        for l in &logs {
            out.push_str(&format!("- `{}`\n", l.path.display()));
        }
        out.push('\n');
    }

    if !scratches.is_empty() {
        out.push_str("### Scratch\n\n");
        for s in &scratches {
            out.push_str(&format!("- `{}`\n", s.path.display()));
        }
        out.push('\n');
    }

    out.push_str("## Store Stats\n\n");
    out.push_str(&format!("- Total documents: {total}\n"));
    out.push_str(&format!("- Plans: {}\n", plans.len()));
    out.push_str(&format!("- Reference: {}\n", references.len()));
    out.push_str(&format!("- Scratch: {}\n", scratches.len()));
    out.push_str(&format!("- Logs: {}\n", logs.len()));

    out.push_str("\n## Resume on Reconnect\n\n");
    out.push_str("1. Call MCP tool `get_resume_context` first\n");
    out.push_str("2. Read `running_summary.md` for current state\n");
    out.push_str("3. Read `plan.md` only if summary references new phases\n");

    out
}

/// Write AGENT-TRACE.md when content changed, using an atomic tmp rename.
pub fn sync(store_root: &Path, manifest: &Manifest, git: &GitStore) -> Result<()> {
    let new_content = generate(store_root, manifest);
    let target = store_root.join("AGENT-TRACE.md");
    if std::fs::read_to_string(&target).unwrap_or_default() == new_content {
        return Ok(());
    }

    let tmp = store_root.join(".agent-trace").join("AGENT-TRACE.md.tmp");
    std::fs::write(&tmp, &new_content)?;
    std::fs::rename(&tmp, &target)?;

    let info = CommitInfo {
        action: Action::Modify,
        files: vec![(
            std::path::PathBuf::from("AGENT-TRACE.md"),
            Action::Modify,
            DocType::Reference,
        )],
        actor: Actor::System,
        summary: "update AGENT-TRACE.md index".into(),
        agent_name: None,
        session_id: None,
    };
    git.commit(&info)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::StoreInfo;
    use crate::manifest::Manifest;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn setup(tmp: &TempDir) -> (PathBuf, Manifest, GitStore) {
        let root = tmp.path().to_path_buf();
        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
        let info = StoreInfo::new("test".into());
        let manifest = Manifest::create_empty(info, &root).unwrap();
        let git = GitStore::init(&root).unwrap();
        (root, manifest, git)
    }

    #[test]
    fn test_generate_empty_store() {
        let tmp = TempDir::new().unwrap();
        let (root, manifest, _git) = setup(&tmp);
        let content = generate(&root, &manifest);
        assert!(content.contains("AGENT-TRACE.md"));
        assert!(content.contains("0 total"));
        assert!(content.contains("Write Permission Rules"));
    }

    #[test]
    fn test_generate_with_documents() {
        let tmp = TempDir::new().unwrap();
        let (root, mut manifest, _git) = setup(&tmp);
        manifest
            .register(&PathBuf::from("prd.md"), DocType::Plan, "")
            .unwrap();
        manifest
            .register(&PathBuf::from("schema.md"), DocType::Reference, "")
            .unwrap();
        manifest
            .register(&PathBuf::from("notes.md"), DocType::Scratch, "")
            .unwrap();

        let content = generate(&root, &manifest);
        assert!(content.contains("prd.md"));
        assert!(content.contains("schema.md"));
        assert!(content.contains("notes.md"));
        assert!(content.contains("3 total"));
    }

    #[test]
    fn test_sync_writes_index() {
        let tmp = TempDir::new().unwrap();
        let (root, mut manifest, git) = setup(&tmp);
        manifest
            .register(&PathBuf::from("prd.md"), DocType::Plan, "")
            .unwrap();

        // Need prd.md to exist to stage it (already in AGENT-TRACE generation we only write AGENT-TRACE.md)
        sync(&root, &manifest, &git).unwrap();
        assert!(root.join("AGENT-TRACE.md").exists());
    }

    #[test]
    fn test_generate_rules_section() {
        let tmp = TempDir::new().unwrap();
        let (root, manifest, _) = setup(&tmp);
        let content = generate(&root, &manifest);
        assert!(content.contains("Write Permission Rules"));
        assert!(content.contains("plan"));
        assert!(content.contains("context"));
        assert!(content.contains("automatically reverted"));
    }
}