#![allow(dead_code)] // WIP scaffolding not yet wired
//! Memory — The agent's git-backed, frontmatter-aware memory filesystem.
//!
//! Every agent has a memory directory at `~/.souveraine/agents/{id}/memory/`
//! containing markdown files with YAML frontmatter, tracked in git.
//!
//! The `memory` tool exposes this to the agent as a unified subcommand interface:
//!
//! ```text
//! memory read system/persona
//! memory write system/persona "new content"
//! memory append journal/2026-05-06 "new entry"
//! memory ls system/
//! memory init
//! memory status
//! memory compact --strategy sliding-window
//! ```
//!
//! Design:
//! - All files require YAML frontmatter with `description`
//! - `read_only: true` in frontmatter blocks writes
//! - Every write is a git commit (auto-commit)
//! - Paths are relative to the agent's memory directory

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use crate::core::compact::CompactionStrategyKind;
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;

// ── Data Types ─────────────────────────────────────────────

/// Parsed memory file with frontmatter and body separated.
#[derive(Debug, Clone)]
pub struct MemoryFile {
    pub frontmatter: MemoryFrontmatter,
    pub body: String,
}

/// YAML frontmatter fields for a memory file.
///
/// `None` fields are omitted on render — never serialized as `key: null`
/// (nulls in frontmatter read as noise and confused agents into copying
/// the pattern). Unknown keys agents add (`name:`, `metadata:`, …) are
/// captured in `extra` and round-tripped verbatim instead of being
/// destroyed on the next rewrite.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFrontmatter {
    /// Human-readable description of this file's purpose (required on
    /// tool-path create; tolerated empty on read so nonconforming files
    /// stay reachable and can be healed by a rewrite).
    #[serde(default)]
    pub description: String,
    /// If "true", the file cannot be modified via the memory tool.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub read_only: Option<String>,
    /// Optional tags for categorization.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    /// Optional max body size in characters. Writes/appends that would exceed
    /// this length are rejected. Closes a gap where upstream memfs write path
    /// bypasses block `limit`.
    ///
    /// Units are characters, not tokens — cheap to enforce without a tokenizer.
    /// Best-practice default for system/ files: 4_000 characters
    /// (~1k tokens). For journal/, leave unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub limit: Option<usize>,
    /// Any other frontmatter keys, preserved across rewrites.
    #[serde(flatten, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub extra: std::collections::BTreeMap<String, serde_yaml::Value>,
}

/// Status of the memory repo.
#[derive(Debug, Clone)]
pub struct MemoryStatus {
    pub agent_id: String,
    pub repo_path: PathBuf,
    pub is_git_repo: bool,
    pub file_count: usize,
    pub last_commit: Option<String>,
    pub has_uncommitted: bool,
    pub remote_url: Option<String>,
}

/// Subcommands for the memory tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryCommand {
    /// Read a memory file by label (path relative to memory dir, .md optional).
    Read { path: String },
    /// Write content to a memory file (creates or replaces).
    Write { path: String, content: String },
    /// Append content to a memory file.
    Append { path: String, content: String },
    /// List files in a memory directory.
    Ls { path: Option<String> },
    /// Show memory repo status.
    Status,
    /// Initialize the memory repo for an agent.
    Init { agent_id: String },
    /// Compact the memory (placeholder — strategy in Stage 5/6).
    Compact { strategy: Option<String> },
    /// Delete a memory file.
    Delete { path: String },
    /// Push/fetch against the shared memfs remote (per-instance branch).
    Sync,
    /// Audit frontmatter health: list frontmatter-bound files missing
    /// the required `description` field. Read-only — never mutates.
    Audit,
}

// ── Git-backed Memory Repository ───────────────────────────────────────────

/// A git-backed memory repository for a single agent.
///
/// Wraps a git2 repository at `~/.souveraine/agents/{id}/memory/`.
/// All memory file operations go through this struct, which handles
/// frontmatter parsing, git commits, and path resolution.
#[derive(Debug, Clone)]
pub struct MemoryRepo {
    agent_id: String,
    /// Root of the memory filesystem.
    root: PathBuf,
    /// Whether to auto-commit after writes.
    auto_commit: bool,
}

impl MemoryRepo {
    /// Open or create a memory repo for the given agent.
    ///
    /// The memory directory is at `{base}/{agent_id}/memory/`.
    pub fn new(agent_id: &str, base: &Path) -> Self {
        let root = base.join(agent_id).join("memory");
        Self {
            agent_id: agent_id.to_string(),
            root,
            auto_commit: true,
        }
    }

    /// Open or create a memory repo using the default base path.
    pub fn new_default(agent_id: &str) -> Self {
        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
        Self::new(agent_id, &home.join(".souveraine").join("agents"))
    }

    /// Open a memory repo at an explicit path (rather than `{base}/{id}/memory`).
    ///
    /// Used when the agent's memory dir is laid out differently — e.g. the
    /// server's `agent_inventory` uses `memory.git/` instead of `memory/`.
    pub fn open(agent_id: &str, root: PathBuf) -> Self {
        Self {
            agent_id: agent_id.to_string(),
            root,
            auto_commit: true,
        }
    }

    /// Initialize the memory directory as a git repo.
    ///
    /// Creates `system/` and sets up the initial commit with placeholder files.
    /// Safe to call multiple times — skips if already a repo.
    pub async fn init(&self) -> Result<()> {
        let mem_path = &self.root;
        tokio::fs::create_dir_all(mem_path.join("system"))
            .await
            .context("creating memory/system directory")?;

        // Check if already a git repo
        let git_dir = mem_path.join(".git");
        if git_dir.exists() {
            info!("Memory repo already initialized for agent {}", self.agent_id);
            return Ok(());
        }

        // Initialize git repo
        let repo = git2::Repository::init(mem_path)
            .context("initializing git repository for memory")?;

        // Set user config for commits (scoped to drop before .await)
        {
            let mut config = repo.config().context("opening repo config")?;
            config.set_str("user.name", &self.agent_id)?;
            config.set_str("user.email", &format!("{}@souveraine.local", self.agent_id))?;
        }

        // Write initial placeholder files with frontmatter
        let persona_content = render_frontmatter(
            &MemoryFrontmatter {
                description: "Agent identity, voice, principles".to_string(),
                tags: Some(vec!["system".to_string()]),
                limit: Some(4_000),
                ..Default::default()
            },
            "# Identity\n\nAgent identity and core principles go here.\n",
        );
        tokio::fs::write(mem_path.join("system/persona.md"), &persona_content)
            .await
            .context("writing persona.md")?;

        let state_content = render_frontmatter(
            &MemoryFrontmatter {
                description: "Current execution state and phase tracking".to_string(),
                limit: Some(2_000),
                ..Default::default()
            },
            "phase: idle\ncurrent_unit: none\n",
        );
        tokio::fs::write(mem_path.join("system/state.md"), &state_content)
            .await
            .context("writing state.md")?;

        // human.md is not written here — the setup wizard or first-run
        // onboarding creates it with the human's name when it has one.

        // Initial commit
        let mut index = repo.index().context("opening git index")?;
        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
            .context("staging initial memory files")?;
        let tree_id = index.write_tree().context("writing git tree")?;
        let tree = repo.find_tree(tree_id)?;
        let signature = git2::Signature::now(
            &self.agent_id,
            &format!("{}@souveraine.local", self.agent_id),
        )?;
        repo.commit(
            Some("HEAD"),
            &signature,
            &signature,
            "feat(init): initialize agent memory",
            &tree,
            &[],
        )?;

        info!(
            "Initialized memory repo for agent {} at {}",
            self.agent_id,
            mem_path.display()
        );
        Ok(())
    }

    /// Initialize the ledger directory structure.
    /// Idempotent — safe to call multiple times, skips existing files.
    ///
    /// Paths are relative to this repo's root (the subconscious agent's own
    /// memfs), so `ledger/` — not `subconscious/ledger/`.
    pub async fn init_subconscious_ledger(&self) -> Result<()> {
        let ledger_files: &[(&str, &str, &str)] = &[
            ("ledger/commitments.md",
             "Promises made by the primary — tracked until fulfilled or explicitly dropped",
             "# Commitments\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] RESOLVED — resolution note`\n"),
            ("ledger/assumptions.md",
             "Assumptions the primary is operating under — flagged for verification",
             "# Assumptions\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n`[YYYY-MM-DD HH:MM] VERIFIED — evidence`\n"),
            ("ledger/patterns.md",
             "Recurring behavioral patterns observed across turns",
             "# Patterns\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
            ("ledger/drift_log.md",
             "Behavioral shifts — when the primary's actions diverge from stated intentions",
             "# Drift Log\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
            ("ledger/relationships.md",
             "Observations about the human-agent relationship — tone shifts, trust signals, friction",
             "# Relationships\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
            ("ledger/infrastructure.md",
             "System events — bridge failures, token issues, model errors, resource constraints",
             "# Infrastructure\n\nAppend entries as:\n`[YYYY-MM-DD HH:MM] content`\n"),
        ];

        for (path, description, body) in ledger_files {
            let full_path = self.root.join(path);
            if !full_path.exists() {
                if let Some(parent) = full_path.parent() {
                    tokio::fs::create_dir_all(parent).await
                        .with_context(|| format!("creating ledger directory: {}", parent.display()))?;
                }
                let template = format!(
                    "---\ndescription: \"{}\"\nread_only: false\ntags:\n  - ledger\n---\n\n{}",
                    description, body
                );
                tokio::fs::write(&full_path, &template).await
                    .with_context(|| format!("writing ledger file: {}", path))?;
                debug!("Created ledger file: {}", path);
            }
        }

        Ok(())
    }

    /// Read a memory file by label (path relative to memory dir, .md optional).
    pub async fn read(&self, label: &str) -> Result<MemoryFile> {
        let path = self.resolve_path(label);
        let content = tokio::fs::read_to_string(&path)
            .await
            .with_context(|| format!("reading memory file: {}", label))?;
        parse_memory_file(&content)
    }

    /// Write content to a memory file (creates or replaces).
    ///
    /// Content should NOT include frontmatter — it will be added automatically.
    /// If the file exists, its frontmatter is preserved (unless changing read_only).
    pub async fn write(&self, label: &str, body: &str) -> Result<()> {
        let path = self.resolve_path(label);

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent)
                .await
                .context("creating parent directories")?;
        }

        // Agent-supplied frontmatter in the content is merged, not nested.
        let (supplied, body) = split_supplied_frontmatter(body);

        // Get existing frontmatter or use default
        let base = if path.exists() {
            let existing = tokio::fs::read_to_string(&path).await?;
            let parsed = parse_memory_file(&existing)?;
            if parsed.frontmatter.read_only.as_deref() == Some("true") {
                return Err(anyhow!("memory file is read_only: {}", label));
            }
            parsed.frontmatter
        } else {
            MemoryFrontmatter {
                description: format!("Memory file: {}", label),
                ..Default::default()
            }
        };
        let frontmatter = match supplied {
            Some(fm) => merge_frontmatter(base, fm),
            None => base,
        };

        // Enforce frontmatter `limit:` (LET-8133 closure).
        if let Some(max) = frontmatter.limit {
            if body.chars().count() > max {
                return Err(anyhow!(
                    "memory write rejected: body is {} chars, limit is {} (file: {})",
                    body.chars().count(),
                    max,
                    label
                ));
            }
        }

        // `description` is required on frontmatter-bound files — it is the
        // file's headline in the prompt assembler, so persisting an empty
        // one is a schema gap. We never fabricate one: the write is rejected
        // loudly and the agent must supply a real description. (Memory-only
        // write could still seed a default, but an agent-supplied empty
        // frontmatter block that clears it must not silently take hold.)
        if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
            return Err(anyhow!(
                "memory write rejected: {} has no description. \
                 `description:` is required frontmatter on this file — \
                 include it in the write (or a `---\\ndescription: ...\\n---` block). \
                 Run `memory audit` to see all files missing it.",
                label
            ));
        }

        let rendered = render_frontmatter(&frontmatter, body);
        tokio::fs::write(&path, &rendered)
            .await
            .with_context(|| format!("writing memory file: {}", label))?;

        if self.auto_commit {
            self.commit(&[label], &format!("memory write: {}", label))?;
        }

        Ok(())
    }

    /// Append content to a memory file.
    pub async fn append(&self, label: &str, content: &str) -> Result<()> {
        let path = self.resolve_path(label);

        // Frontmatter never belongs mid-file; merge it instead of nesting.
        let (supplied, content) = split_supplied_frontmatter(content);

        if path.exists() {
            let existing = tokio::fs::read_to_string(&path).await?;
            let parsed = parse_memory_file(&existing)?;
            if parsed.frontmatter.read_only.as_deref() == Some("true") {
                return Err(anyhow!("memory file is read_only: {}", label));
            }
            let frontmatter = match supplied {
                Some(fm) => merge_frontmatter(parsed.frontmatter, fm),
                None => parsed.frontmatter,
            };
            // Write back body + new content, preserving frontmatter
            let new_body = if parsed.body.is_empty() {
                content.to_string()
            } else {
                format!("{}\n{}", parsed.body.trim_end(), content)
            };
            // Enforce frontmatter `limit:` (LET-8133 closure).
            if let Some(max) = frontmatter.limit {
                if new_body.chars().count() > max {
                    return Err(anyhow!(
                        "memory append rejected: body would be {} chars, limit is {} (file: {})",
                        new_body.chars().count(),
                        max,
                        label
                    ));
                }
            }
            // `description` is required on frontmatter-bound files. Appending
            // to a file that already lacks one must not silently perpetuate
            // the gap — reject loudly so the agent heals the description
            // before growing the file. (See `write` for the same guard.)
            if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
                return Err(anyhow!(
                    "memory append rejected: {} has no description. \
                     `description:` is required frontmatter on this file — \
                     heal it with a `memory write` (include a `description:` block) \
                     before appending. Run `memory audit` to see all such files.",
                    label
                ));
            }
            let rendered = render_frontmatter(&frontmatter, &new_body);
            tokio::fs::write(&path, &rendered).await?;

            if self.auto_commit {
                self.commit(&[label], &format!("memory append: {}", label))?;
            }
            return Ok(());
        };

        // File doesn't exist — create it with supplied or default frontmatter
        let frontmatter = merge_frontmatter(
            MemoryFrontmatter {
                description: format!("Memory file: {}", label),
                ..Default::default()
            },
            supplied.unwrap_or_default(),
        );
        let rendered = render_frontmatter(&frontmatter, content);
        tokio::fs::write(&path, &rendered).await?;

        if self.auto_commit {
            self.commit(&[label], &format!("memory append: {}", label))?;
        }

        Ok(())
    }

    /// List files in a memory directory.
    pub async fn list(&self, subdir: Option<&str>) -> Result<Vec<String>> {
        let dir = match subdir {
            Some(d) => self.root.join(d),
            None => self.root.clone(),
        };

        let mut entries = Vec::new();
        let mut read_dir = tokio::fs::read_dir(&dir)
            .await
            .with_context(|| format!("listing memory directory: {}", dir.display()))?;

        while let Some(entry) = read_dir.next_entry().await? {
            let name = entry.file_name().to_string_lossy().to_string();
            // Skip .git directory
            if name == ".git" {
                continue;
            }
            let kind = entry.file_type().await?;
            if kind.is_dir() {
                entries.push(format!("{}/", name));
            } else {
                entries.push(name);
            }
        }

        entries.sort();
        Ok(entries)
    }

    /// Recursively collect every `.md` under the memory root as a path
    /// relative to the root (forward slashes), skipping `.git*`. Internal
    /// helper for `audit`.
    async fn walk_md_relative(&self) -> Result<Vec<String>> {
        let mut out = Vec::new();
        let root = self.root.clone();
        let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
        while let Some((dir, rel_prefix)) = stack.pop() {
            let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| {
                format!("walking memory directory: {}", dir.display())
            })?;
            while let Some(entry) = read_dir.next_entry().await? {
                let name = entry.file_name().to_string_lossy().to_string();
                if name.starts_with(".git") {
                    continue;
                }
                let kind = entry.file_type().await?;
                let rel = if rel_prefix.is_empty() {
                    name.clone()
                } else {
                    format!("{rel_prefix}/{name}")
                };
                if kind.is_dir() {
                    stack.push((entry.path(), rel));
                } else if kind.is_file() && name.ends_with(".md") {
                    out.push(rel);
                }
            }
        }
        out.sort();
        Ok(out)
    }

    /// Audit frontmatter health: return the relative paths of
    /// frontmatter-bound memory files whose `description` is empty. Read-only.
    ///
    /// Excludes files that legitimately carry no `description` because they
    /// use a different schema — `tasks/` (TodoItem), `system/dynamic/`
    /// (itinerary), `journal/` (freeform) — by reusing `is_frontmatter_bound`.
    /// Parse failures are tolerated (a file must stay reachable to be healed),
    /// matching the tolerant-on-read contract of `parse_memory_file`.
    pub async fn audit(&self) -> Result<Vec<String>> {
        let mut missing = Vec::new();
        for rel in self.walk_md_relative().await? {
            if !Self::is_frontmatter_bound(&rel) {
                continue;
            }
            let path = self.root.join(&rel);
            let content = match tokio::fs::read_to_string(&path).await {
                Ok(c) => c,
                Err(_) => continue, // don't let one unreadable file abort the audit
            };
            let parsed = parse_memory_file(&content)?;
            if parsed.frontmatter.description.trim().is_empty() {
                missing.push(rel);
            }
        }
        Ok(missing)
    }

    /// Delete a memory file.
    pub async fn delete(&self, label: &str) -> Result<()> {
        let path = self.resolve_path(label);

        if !path.exists() {
            return Err(anyhow!("memory file not found: {}", label));
        }

        // Check not read_only
        let content = tokio::fs::read_to_string(&path).await?;
        let parsed = parse_memory_file(&content)?;
        if parsed.frontmatter.read_only.as_deref() == Some("true") {
            return Err(anyhow!("memory file is read_only: {}", label));
        }

        tokio::fs::remove_file(&path).await
            .with_context(|| format!("deleting memory file: {}", label))?;

        if self.auto_commit {
            self.commit(&[label], &format!("memory delete: {}", label))?;
        }

        Ok(())
    }

    /// Get the status of the memory repo.
    pub fn status(&self) -> Result<MemoryStatus> {
        let repo_path = self.root.clone();
        let is_git_repo = repo_path.join(".git").exists();

        let mut file_count = 0;
        if let Ok(entries) = std::fs::read_dir(&repo_path) {
            for entry in entries.flatten() {
                let name = entry.file_name().to_string_lossy().to_string();
                if name != ".git" && name.ends_with(".md") {
                    file_count += 1;
                }
            }
        }

        let (last_commit, has_uncommitted, remote_url) = if is_git_repo {
            match git2::Repository::open(&repo_path) {
                Ok(repo) => {
                    let lc = repo.head().ok().and_then(|h| {
                        h.peel_to_commit().ok().map(|c| {
                            c.message().unwrap_or("(unknown)").to_string()
                        })
                    });
                    let dirty = repo.statuses(Some(
                        git2::StatusOptions::new().include_untracked(true),
                    ))
                    .map(|s| s.iter().any(|_| true))
                    .unwrap_or(false);
                    let remote = repo.find_remote("origin").ok()
                        .and_then(|r| r.url().map(|u| u.to_string()));
                    (lc, dirty, remote)
                }
                Err(_) => (None, false, None),
            }
        } else {
            (None, false, None)
        };

        Ok(MemoryStatus {
            agent_id: self.agent_id.clone(),
            repo_path,
            is_git_repo,
            file_count,
            last_commit,
            has_uncommitted,
            remote_url,
        })
    }

    /// Commit staged changes to the memory repo.
    pub fn commit(&self, paths: &[&str], message: &str) -> Result<()> {
        let repo = self.open_git()?;
        let mut index = repo.index().context("opening git index")?;

        for path in paths {
            let rel_path = self.to_relative(path);
            // Try with .md extension if not present
            let md_path = if rel_path.ends_with(".md") {
                rel_path.clone()
            } else {
                format!("{}.md", rel_path)
            };

            if self.root.join(&md_path).exists() {
                index.add_path(Path::new(&md_path))?;
            } else if self.root.join(&rel_path).exists() {
                index.add_path(Path::new(&rel_path))?;
            }
        }

        let tree_id = index.write_tree().context("writing tree")?;
        let tree = repo.find_tree(tree_id)?;
        let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
        let parents: Vec<&git2::Commit> = parent.iter().collect();

        let signature = git2::Signature::now(
            &self.agent_id,
            &format!("{}@souveraine.local", self.agent_id),
        )?;

        repo.commit(
            Some("HEAD"),
            &signature,
            &signature,
            message,
            &tree,
            &parents,
        )?;

        debug!("Committed to memory: {}", message);
        Ok(())
    }

    /// Hex hash of the current HEAD commit, if the repo has one.
    pub fn head_commit_hex(&self) -> Option<String> {
        let repo = self.open_git().ok()?;
        let head = repo.head().ok()?;
        head.peel_to_commit().ok().map(|c| c.id().to_string())
    }

    /// Branch shorthand HEAD points at ("main", "primary", ...), if any.
    pub fn current_branch(&self) -> Option<String> {
        let repo = self.open_git().ok()?;
        let head = repo.head().ok()?;
        head.shorthand().map(|s| s.to_string())
    }

    /// Sync this memfs against its shared remote: fetch everything, then
    /// push HEAD to this instance's branch (`instance/{label}`).
    ///
    /// This is the manual floor of federated memory transport
    /// (FEDERATION.md): every instance writes its own branch on one bare
    /// remote; reconciliation/merge is a separate, later step — sync never
    /// touches the working tree. Shells out to system git so credentials
    /// (ssh config, credential store) work the way they do everywhere else.
    ///
    /// Loud on every failure. Requires an `origin` remote — provisioning is
    /// deliberately explicit until the node commission ceremony exists.
    pub async fn sync(&self, instance_label: &str) -> Result<String> {
        if instance_label.is_empty()
            || instance_label.len() > 64
            || !instance_label
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
        {
            return Err(anyhow!("invalid instance label: {:?}", instance_label));
        }

        let repo = self.open_git()?;
        if repo.find_remote("origin").is_err() {
            return Err(anyhow!(
                "no shared remote configured for this memfs. Provision one \
                 (bare repo on the Gitea) and run:\n  git -C {} remote add origin <url>",
                self.root.display()
            ));
        }
        drop(repo);

        let fetch = self.git(&["fetch", "origin", "--prune"]).await?;
        let branch = format!("instance/{}", instance_label);
        let push_ref = format!("HEAD:refs/heads/{}", branch);
        let push = self.git(&["push", "origin", &push_ref]).await?;

        let instances = self
            .git(&[
                "for-each-ref",
                "--format=%(refname:short) %(objectname:short)",
                "refs/remotes/origin/instance/",
            ])
            .await
            .unwrap_or_default();

        let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
        let mut out = format!("Synced. This instance is {} @ {}\n", branch, &head[..12.min(head.len())]);
        if !fetch.trim().is_empty() {
            out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
        }
        if !push.trim().is_empty() {
            out.push_str(&format!("Pushed:\n{}\n", push.trim()));
        }
        if !instances.trim().is_empty() {
            out.push_str(&format!("Instances on the remote:\n{}\n", instances.trim()));
        }
        Ok(out)
    }

    /// Run a git subcommand against this repo, capturing combined output.
    /// Errors carry git's stderr — loud, never swallowed.
    async fn git(&self, args: &[&str]) -> Result<String> {
        let output = tokio::process::Command::new("git")
            .arg("-C")
            .arg(&self.root)
            .args(args)
            .output()
            .await
            .context("running git — memory sync needs the git binary on PATH")?;
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        if !output.status.success() {
            return Err(anyhow!(
                "git {} failed ({}):\n{}",
                args.first().unwrap_or(&"?"),
                output.status,
                stderr.trim()
            ));
        }
        Ok(format!("{}{}", stdout, stderr))
    }

    /// Get the root path of the memory repo.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Get the agent ID.
    pub fn agent_id(&self) -> &str {
        &self.agent_id
    }

    // ── Internal helpers ──────────────────────────────────────────────────

    fn open_git(&self) -> Result<git2::Repository> {
        git2::Repository::open(&self.root)
            .context("opening memory git repository")
    }

    /// Whether the `description` frontmatter contract applies to a label.
    ///
    /// `description` is required on `MemoryFrontmatter` files — the prompt
    /// assembler reads it as the file's one-line headline, so an empty one
    /// is a real schema gap, not cosmetic. But not every `.md` under the
    /// memory tree is a `MemoryFrontmatter` file: `tasks/` uses the
    /// `TodoItem` schema, `system/dynamic/` holds the itinerary (its own
    /// richer, unfinished schema), and `journal/` is freeform prose. Those
    /// legitimately lack `description`; enforcing it there would reject
    /// valid writes. This predicate scopes the rule to frontmatter-bound
    /// locations only.
    fn is_frontmatter_bound(label: &str) -> bool {
        let rel = label.trim().trim_start_matches(['/', '.']).replace('\\', "/");
        let lower = rel.to_ascii_lowercase();
        // Exact segment prefixes, not substring matches, so a file like
        // `reference/tasks-today.md` is not mistaken for a todo file.
        let under = |seg: &str| {
            lower == seg
                || lower.starts_with(&format!("{seg}/"))
                || lower.starts_with(&format!("{seg}\\"))
        };
        !(under("tasks") || under("system/dynamic") || under("journal"))
    }

    fn resolve_path(&self, label: &str) -> PathBuf {
        let clean = label.trim().trim_end_matches(".md");
        self.root.join(format!("{}.md", clean))
    }

    fn to_relative(&self, path: &str) -> String {
        path.trim().trim_end_matches(".md").replace('\\', "/")
    }
}

// ── Frontmatter Parsing ────────────────────────────────────────────────────

/// Parse a memory file, separating frontmatter from body.
///
/// Expected format:
/// ```markdown
/// ---
/// description: Purpose of this file
/// read_only: true
/// ---
/// Body content here...
/// ```
/// Split a leading `--- … ---` frontmatter block off `content`.
/// Returns `(frontmatter_yaml, body)` or `None` when there is no block.
fn split_frontmatter_block(content: &str) -> Option<(&str, &str)> {
    let content = content.trim_start();
    if !content.starts_with("---") {
        return None;
    }
    let after_first = &content[3..];
    let end_idx = after_first
        .find("\n---")
        .or_else(|| after_first.find("\r\n---"))?;
    let frontmatter_text = after_first[..end_idx].trim();
    let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
    Some((frontmatter_text, content[body_start..].trim_start_matches(['\r', '\n'])))
}

/// Parse a memory file. Tolerant on read: a file with no frontmatter, or
/// frontmatter that fails YAML parsing, comes back with the whole content
/// as body and default (empty-description) frontmatter — a file that
/// exists must always be readable through the tool, otherwise the agent
/// can never heal it. Strictness (description required) belongs to the
/// write path, not here.
pub fn parse_memory_file(content: &str) -> Result<MemoryFile> {
    let Some((frontmatter_text, body)) = split_frontmatter_block(content) else {
        return Ok(MemoryFile {
            frontmatter: MemoryFrontmatter::default(),
            body: content.trim().to_string(),
        });
    };

    match serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
        Ok(frontmatter) => Ok(MemoryFile {
            frontmatter,
            body: body.trim().to_string(),
        }),
        // Malformed YAML: keep the raw text intact as body so a rewrite
        // cannot silently destroy whatever the block was trying to say.
        Err(_) => Ok(MemoryFile {
            frontmatter: MemoryFrontmatter::default(),
            body: content.trim().to_string(),
        }),
    }
}

/// Agents sometimes include their own frontmatter in write/append content
/// despite the "body only" contract. Instead of nesting a second `---`
/// block inside the body (the persona.md failure mode), honor it: parse
/// it off and merge its fields. Content whose leading block is not valid
/// YAML is left untouched — never destroy what we cannot parse.
fn split_supplied_frontmatter(content: &str) -> (Option<MemoryFrontmatter>, &str) {
    if let Some((frontmatter_text, body)) = split_frontmatter_block(content) {
        if let Ok(fm) = serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
            return (Some(fm), body);
        }
    }
    (None, content)
}

/// Overlay agent-supplied frontmatter onto the file's existing (or
/// default) frontmatter: supplied fields win where set, everything else
/// is preserved. `read_only` is deliberately NOT overridable from
/// supplied content — clearing it requires the explicit tool path.
fn merge_frontmatter(base: MemoryFrontmatter, supplied: MemoryFrontmatter) -> MemoryFrontmatter {
    let mut extra = base.extra;
    extra.extend(supplied.extra);
    MemoryFrontmatter {
        description: if supplied.description.trim().is_empty() {
            base.description
        } else {
            supplied.description
        },
        read_only: base.read_only,
        tags: supplied.tags.or(base.tags),
        limit: supplied.limit.or(base.limit),
        extra,
    }
}

/// Render frontmatter + body into a complete memory file.
pub fn render_frontmatter(fm: &MemoryFrontmatter, body: &str) -> String {
    let yaml = serde_yaml::to_string(fm).unwrap_or_default();
    format!("---\n{}---\n{}", yaml, body)
}

/// Read a memory file from disk by path (for external use).
pub async fn read_file(path: &Path) -> Result<MemoryFile> {
    let content = tokio::fs::read_to_string(path)
        .await
        .context("reading memory file")?;
    parse_memory_file(&content)
}

// ── Tool Interface ─────────────────────────────────────────────────────────

/// Fire a `memfs_commit` presence signal after a memfs mutation.
///
/// This is the federation heartbeat for memory: "I wrote, you should fetch."
/// It carries routing metadata only — never the data itself. Peers holding a
/// checkout of the same agent map `agent_pubkey` to their local repo and
/// fetch the shared remote (FEDERATION.md, memory transport).
///
/// Emission is best-effort and never fails the write: the mutation already
/// succeeded. Missing identity is warned loudly, not fabricated.
fn fire_memfs_commit(
    ctx: &ToolContext,
    repo: &MemoryRepo,
    op: &str,
    paths: &[&str],
    urgency: f32,
) {
    // A successful tool-path mutation auto-committed; if there is somehow no
    // head, there is nothing for a peer to fetch — do not signal.
    let Some(commit) = repo.head_commit_hex() else {
        tracing::warn!(op, ?paths, "memfs mutation without a head commit — memfs_commit not emitted");
        return;
    };
    let branch = repo.current_branch();

    // Agent identity lives beside the memfs (`agents/{id}/seed/`) — same
    // convention as reach/consult in tools/agent.rs.
    let agent_pubkey = ctx
        .memory_root
        .as_ref()
        .and_then(|m| m.parent())
        .map(|p| p.join("seed"))
        .and_then(|dir| match crate::core::identity::SeedId::load_or_generate(&dir) {
            Ok(seed) => Some(seed.public_key_hex()),
            Err(e) => {
                tracing::warn!(op, "memfs_commit: agent seed unavailable ({e:#})");
                None
            }
        });

    // The instance is the machine that wrote — machined-first, loud legacy
    // fallback, never generates.
    let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
    let instance = match crate::machined::client::machine_pubkey_with_fallback(&base) {
        Ok((pk, _source)) => Some(pk),
        Err(e) => {
            tracing::warn!(op, "memfs_commit: machine identity unavailable ({e:#})");
            None
        }
    };

    ctx.fire_event(crate::core::nervous::SensorEvent {
        sensor_name: "memory".into(),
        timestamp: chrono::Utc::now(),
        event_type: "memfs_commit".into(),
        target: paths.first().map(|p| p.to_string()),
        urgency,
        payload: Some(serde_json::json!({
            "op": op,
            "agent_pubkey": agent_pubkey,
            "instance": instance,
            "paths": paths,
            "commit": commit,
            "branch": branch,
        })),
        seed_id: None,
        reply_to: None,
    });
}

/// Execute a memory command, optionally using context for agent identity.
///
/// When `ctx` is `Some` and carries an `agent_id`, that takes precedence over
/// environment variables. Falls back to env vars when no context is provided,
/// preserving backward compatibility with the HTTP server path.
pub async fn execute_memory_command_with_context(
    cmd: &MemoryCommand,
    ctx: Option<&ToolContext>,
) -> Result<String> {
    // Agent ID resolution: context > command > env var > default
    let agent_id = ctx
        .and_then(|c| c.agent_id.as_ref())
        .or(match cmd {
            MemoryCommand::Init { agent_id } => Some(agent_id),
            _ => None,
        })
        .cloned()
        .or_else(|| std::env::var("SOUVERAINE_AGENT").ok())
        .or_else(|| std::env::var("AGENT_ID").ok())
        .unwrap_or_else(|| "default".to_string());

    // Memory root resolution: use memory_root from context when available
    let repo = match ctx.and_then(|c| c.memory_root.as_ref()) {
        Some(root) => MemoryRepo::open(&agent_id, root.clone()),
        None => MemoryRepo::new_default(&agent_id),
    };

    match cmd {
        MemoryCommand::Init { .. } => {
            repo.init().await?;
            Ok(format!("Initialized memory repo for agent: {}", agent_id))
        }
        MemoryCommand::Read { path } => {
            let file = repo.read(path).await?;
            // Normalized render: full frontmatter (description, tags,
            // limit, extras — no nulls), so what the agent reads matches
            // what a rewrite would produce.
            Ok(render_frontmatter(&file.frontmatter, &file.body))
        }
        MemoryCommand::Write { path, content } => {
            repo.write(path, content).await?;
            if let Some(c) = ctx {
                fire_memfs_commit(c, &repo, "write", &[path], 0.1);
            }
            Ok(format!("Wrote memory file: {}", path))
        }
        MemoryCommand::Append { path, content } => {
            repo.append(path, content).await?;
            if let Some(c) = ctx {
                fire_memfs_commit(c, &repo, "append", &[path], 0.1);
            }
            Ok(format!("Appended to memory file: {}", path))
        }
        MemoryCommand::Ls { path } => {
            let entries = repo.list(path.as_deref()).await?;
            if entries.is_empty() {
                Ok("(empty)".to_string())
            } else {
                Ok(entries.join("\n"))
            }
        }
        MemoryCommand::Status => {
            let status = repo.status()?;
            let mut out = format!(
                "Agent: {}\nPath: {}\nGit repo: {}\nFiles: {}\n",
                status.agent_id,
                status.repo_path.display(),
                status.is_git_repo,
                status.file_count,
            );
            if let Some(ref lc) = status.last_commit {
                out.push_str(&format!("Last commit: {}\n", lc));
            }
            out.push_str(&format!("Uncommitted: {}\n", status.has_uncommitted));
            if let Some(ref url) = status.remote_url {
                out.push_str(&format!("Remote: {}\n", url));
            }
            Ok(out)
        }
        MemoryCommand::Compact { strategy } => {
            // None when no --strategy was given: the engine then resolves the
            // per-agent-type default (cfg.strategy via for_agent_type) instead
            // of being force-pinned to Cull at the call site.
            let strategy_kind = strategy
                .as_deref()
                .and_then(CompactionStrategyKind::from_str);

            match ctx.and_then(|c| c.compaction_engine.as_ref()) {
                Some(engine) => {
                    let report = engine
                        .compact(&agent_id, strategy_kind)
                        .await?;
                    Ok(report.to_string())
                }
                None => Ok(
                    "I can compact my context window using one of these strategies:\n\
                     - sliding_window (keep first + last N messages, drop the middle — fast, no LLM)\n\
                     - summary (LLM-summarize oldest messages into a compact block)\n\
                     - microcompact (replace old tool results with placeholders — drop-in, no LLM)\n\
                     - cull (drop greetings and acknowledgments — cheapest)\n\n\
                     Usage: memory compact --strategy <strategy>\n\
                     Each agent type has its own default: primary=sliding_window, subconscious=sliding_window, subagent=cull"
                        .to_string(),
                ),
            }
        }
        MemoryCommand::Delete { path } => {
            repo.delete(path).await?;
            if let Some(c) = ctx {
                fire_memfs_commit(c, &repo, "delete", &[path], 0.2);
            }
            Ok(format!("Deleted memory file: {}", path))
        }
        MemoryCommand::Sync => {
            // The instance branch is named by machine identity — no
            // identity, no sync. Interim label: machine pubkey prefix;
            // commission-ceremony labels come later (FEDERATION.md flag).
            let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
            let (pubkey, _source) =
                crate::machined::client::machine_pubkey_with_fallback(&base).context(
                    "memory sync needs a machine identity to name this instance's branch",
                )?;
            let label: String = pubkey.chars().take(12).collect();
            repo.sync(&label).await
        }
        MemoryCommand::Audit => {
            // Read-only health check: which frontmatter-bound files lack the
            // required `description`? Never mutates — surfacing the gap is the
            // point. `write`/`append` will reject attempts to perpetuate one.
            let missing = repo.audit().await?;
            if missing.is_empty() {
                Ok("All frontmatter-bound memory files carry a description.".to_string())
            } else {
                let mut out = format!(
                    "{} frontmatter-bound file(s) missing `description:`:\n",
                    missing.len()
                );
                for rel in &missing {
                    out.push_str(&format!("  - {rel}\n"));
                }
                out.push_str(
                    "Heal each with a `memory write` that includes a `description:` \
                     block (or set it inline). These files reject append until healed.\n",
                );
                Ok(out)
            }
        }
    }
}

/// Execute a memory command, reading agent identity from env vars.
/// Delegates to `execute_memory_command_with_context` with `None`.
pub async fn execute_memory_command(cmd: &MemoryCommand) -> Result<String> {
    execute_memory_command_with_context(cmd, None).await
}

// ── Tool Result Bridge ──────────────────────────────────────────────────────

use crate::core::tools::ToolResult;

/// Handle a memory tool invocation with optional per-agent context.
///
/// Parses JSON input, builds a MemoryCommand, executes it via the
/// context-aware path, wraps in ToolResult.
pub async fn handle_memory_tool_with_context(
    tool_name: &str,
    input: &serde_json::Value,
    ctx: Option<&ToolContext>,
) -> ToolResult {
    let tool_use_id = format!("tool-u-{}", chrono::Utc::now().timestamp_millis());
    let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");

    let cmd = match command {
        "read" => {
            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
            MemoryCommand::Read { path }
        }
        "write" => {
            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
            MemoryCommand::Write { path, content }
        }
        "append" => {
            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
            MemoryCommand::Append { path, content }
        }
        "ls" => {
            let path = input.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
            MemoryCommand::Ls { path }
        }
        "status" => MemoryCommand::Status,
        "init" => {
            let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("default").to_string();
            MemoryCommand::Init { agent_id }
        }
        "delete" => {
            let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
            MemoryCommand::Delete { path }
        }
        "compact" => {
            let strategy = input.get("strategy").and_then(|v| v.as_str()).map(|s| s.to_string());
            MemoryCommand::Compact { strategy }
        }
        "sync" => MemoryCommand::Sync,
        "audit" => MemoryCommand::Audit,
        _ => {
            return ToolResult {
                tool_use_id,
                tool_name: tool_name.to_string(),
                output: format!(
                    "Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact, sync, audit",
                    command
                ),
                is_error: true,
            };
        }
    };

    match execute_memory_command_with_context(&cmd, ctx).await {
        Ok(output) => ToolResult {
            tool_use_id,
            tool_name: tool_name.to_string(),
            output,
            is_error: false,
        },
        Err(e) => ToolResult {
            tool_use_id,
            tool_name: tool_name.to_string(),
            output: format!("Error: {e}"),
            is_error: true,
        },
    }
}

/// Handle a memory tool invocation (backward-compatible, no context).
/// Delegates to `handle_memory_tool_with_context` with `None`.
pub async fn handle_memory_tool(tool_name: &str, input: &serde_json::Value) -> ToolResult {
    handle_memory_tool_with_context(tool_name, input, None).await
}

// ── Tool Definitions ───────────────────────────────────────────────────────

/// Tool definition for the `memory` tool — the agent's access to her own thoughts.
///
/// Memory is a separate channel from filesystem read/write. Every memory file has
/// frontmatter (description, tags, read_only, limit), is git-tracked, and paths
/// are relative to the agent's memory root.
pub fn memory_tool_definition() -> ToolDefinition {
    ToolDefinition {
        name: "memory".to_string(),
        description: "I reach into my own memory. Every file here has frontmatter — a description, boundaries (read_only), tags. When I read, I see what I've written about myself. When I write, I change who I am. The git commit is my heartbeat — I know when I last changed.

Subcommands:
  read    — Open a memory file. Frontmatter is handled for me — I see the body.
  write   — Write to a memory file. Frontmatter is preserved or auto-generated.
  append  — Add to a memory file without disturbing its frontmatter.
  ls      — List files in a memory directory.
  status  — Check my memory's git state: uncommitted changes, last commit.
  init    — Initialize a new memory repo for a given agent ID.
  delete  — Delete a memory file (validates read_only first).
  compact — Compact the memory window to free context space.
  sync    — Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how.
  audit   — List frontmatter-bound files missing the required `description` (read-only). `write`/`append` reject a missing description, so this is how I find what to heal.

Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(),
        input_schema: serde_json::json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact", "sync", "audit"],
                    "description": "What to do with my memory"
                },
                "path": {
                    "type": "string",
                    "description": "Path relative to memory directory (e.g., system/persona, journal/2026-05-06)"
                },
                "content": {
                    "type": "string",
                    "description": "Content to write or append — body only, no frontmatter"
                },
                "strategy": {
                    "type": "string",
                    "enum": ["microcompact", "sliding_window", "summary", "cull"],
                    "description": "Compaction strategy (for compact subcommand). sliding_window (fast, drops middle), summary (LLM), microcompact (tool-result placeholder), cull (greetings)"
                }
            },
            "required": ["command"]
        }),
    }
}

// ── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_parse_frontmatter() {
        let content = "---\ndescription: Test file\nread_only: false\n---\nHello world";
        let file = parse_memory_file(content).unwrap();
        assert_eq!(file.frontmatter.description, "Test file");
        assert_eq!(file.body, "Hello world");
    }

    #[test]
    fn test_parse_missing_frontmatter() {
        // Tolerant read: no frontmatter means the whole content is body.
        let content = "Hello world without frontmatter";
        let file = parse_memory_file(content).unwrap();
        assert_eq!(file.body, content);
        assert!(file.frontmatter.description.is_empty());
    }

    #[test]
    fn test_render_omits_none_fields() {
        let fm = MemoryFrontmatter {
            description: "Bare defaults".to_string(),
            ..Default::default()
        };
        let rendered = render_frontmatter(&fm, "Body");
        assert!(!rendered.contains("null"), "None fields must be omitted: {rendered}");
    }

    #[test]
    fn test_extra_keys_roundtrip() {
        let content = "---\ndescription: Has extras\nname: my-slug\nkind: feedback\n---\nBody";
        let file = parse_memory_file(content).unwrap();
        assert_eq!(file.frontmatter.extra.len(), 2);
        let rendered = render_frontmatter(&file.frontmatter, &file.body);
        assert!(rendered.contains("name: my-slug"));
        assert!(rendered.contains("kind: feedback"));
    }

    #[test]
    fn test_supplied_frontmatter_not_nested() {
        let content = "---\ndescription: Supplied\n---\nActual body";
        let (fm, body) = split_supplied_frontmatter(content);
        assert_eq!(fm.unwrap().description, "Supplied");
        assert_eq!(body, "Actual body");
    }

    #[test]
    fn test_render_and_parse_roundtrip() {
        let fm = MemoryFrontmatter {
            description: "Roundtrip test".to_string(),
            tags: Some(vec!["test".to_string()]),
            ..Default::default()
        };
        let rendered = render_frontmatter(&fm, "Body content");
        let parsed = parse_memory_file(&rendered).unwrap();
        assert_eq!(parsed.frontmatter.description, "Roundtrip test");
        assert_eq!(parsed.body, "Body content");
    }

    #[tokio::test]
    async fn test_memory_repo_init() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        assert!(repo.root().join(".git").exists());
        assert!(repo.root().join("system/persona.md").exists());
        assert!(repo.root().join("system/state.md").exists());
    }

    #[tokio::test]
    async fn test_memory_repo_write_and_read() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        repo.write("test/hello", "Hello memory world").await.unwrap();
        let file = repo.read("test/hello").await.unwrap();
        assert_eq!(file.body, "Hello memory world");
    }

    #[tokio::test]
    async fn sync_without_remote_errors_with_provisioning_hint() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        let err = repo.sync("abc123").await.unwrap_err().to_string();
        assert!(err.contains("remote add origin"), "got: {err}");
    }

    #[tokio::test]
    async fn sync_rejects_invalid_instance_labels() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
            assert!(repo.sync(bad).await.is_err(), "label {bad:?} should be rejected");
        }
    }

    #[tokio::test]
    async fn sync_pushes_instance_branch_to_bare_remote() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();
        repo.write("journal/entry", "first thought").await.unwrap();

        let remote_dir = TempDir::new().unwrap();
        let bare = remote_dir.path().join("memfs.git");
        git2::Repository::init_bare(&bare).unwrap();
        git2::Repository::open(repo.root())
            .unwrap()
            .remote("origin", bare.to_str().unwrap())
            .unwrap();

        let report = repo.sync("deadbeef0123").await.unwrap();
        assert!(report.contains("instance/deadbeef0123"), "got: {report}");

        // The bare remote must now hold this instance's branch at our head.
        let remote_repo = git2::Repository::open_bare(&bare).unwrap();
        let branch = remote_repo
            .find_reference("refs/heads/instance/deadbeef0123")
            .unwrap();
        assert_eq!(
            branch.target().unwrap().to_string(),
            repo.head_commit_hex().unwrap()
        );
    }

    #[tokio::test]
    async fn test_memory_repo_read_only() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        // Write a read_only file
        let fm = MemoryFrontmatter {
            description: "Read-only test".to_string(),
            read_only: Some("true".to_string()),
            ..Default::default()
        };
        let content = render_frontmatter(&fm, "This is read-only");
        let path = repo.root().join("test/readonly.md");
        tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();
        tokio::fs::write(&path, &content).await.unwrap();

        // Try to write to it — should fail
        let result = repo.write("test/readonly", "new content").await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("read_only"));
    }

    #[tokio::test]
    async fn test_memory_repo_list() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        let entries = repo.list(None).await.unwrap();
        assert!(entries.iter().any(|e| e == "system/"));
    }

    #[tokio::test]
    async fn test_ledger_init_creates_files_with_frontmatter() {
        let dir = TempDir::new().unwrap();
        let root = dir.path().to_path_buf();
        let repo = MemoryRepo::open("test-sub", root.clone());
        std::fs::create_dir_all(&root).unwrap();
        repo.init_subconscious_ledger().await.unwrap();

        let commitments = root.join("ledger/commitments.md");
        assert!(commitments.exists(), "commitments.md should exist");
        let content = std::fs::read_to_string(&commitments).unwrap();
        assert!(content.starts_with("---\n"), "should have YAML frontmatter");
        assert!(content.contains("description:"), "should have description field");
        assert!(content.contains("tags:"), "should have tags field");
        assert!(content.contains("# Commitments"), "should have body");

        let relationships = root.join("ledger/relationships.md");
        assert!(relationships.exists(), "relationships.md should exist");

        let infrastructure = root.join("ledger/infrastructure.md");
        assert!(infrastructure.exists(), "infrastructure.md should exist");
    }

    #[tokio::test]
    async fn test_ledger_init_is_idempotent() {
        let dir = TempDir::new().unwrap();
        let root = dir.path().to_path_buf();
        let repo = MemoryRepo::open("test-sub", root.clone());
        std::fs::create_dir_all(&root).unwrap();
        repo.init_subconscious_ledger().await.unwrap();

        let commitments = root.join("ledger/commitments.md");
        let before = std::fs::read_to_string(&commitments).unwrap();

        repo.init_subconscious_ledger().await.unwrap();
        let after = std::fs::read_to_string(&commitments).unwrap();
        assert_eq!(before, after, "second init should not overwrite");
    }

    // ── description-required guard + audit ──────────────────────────────

    #[test]
    fn test_is_frontmatter_bound_scoping() {
        // Frontmatter-bound: the description contract applies.
        assert!(MemoryRepo::is_frontmatter_bound("system/persona"));
        assert!(MemoryRepo::is_frontmatter_bound("reference/arch.md"));
        assert!(MemoryRepo::is_frontmatter_bound("projects/plan.md"));
        assert!(MemoryRepo::is_frontmatter_bound("issues/bug.md"));

        // Different schemas that legitimately lack description.
        assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
        assert!(!MemoryRepo::is_frontmatter_bound("system/dynamic/itinerary.md"));
        assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));

        // Segment prefixes, not substring matches — a file named like a
        // todo but living elsewhere is still bound.
        assert!(MemoryRepo::is_frontmatter_bound("reference/tasks-today.md"));
        assert!(MemoryRepo::is_frontmatter_bound("projects/journal-of-x.md"));
    }

    /// Seed a file at `rel` under the repo root, creating parent directories.
    /// `init()` scaffolds only `system/`; the other schema roots (issues/,
    /// projects/, reference/, tasks/, journal/) are created on first write by
    /// the real write path, so tests seeding fixtures directly must mkdir -p.
    fn seed_file(repo: &MemoryRepo, rel: &str, contents: &str) {
        let path = repo.root().join(rel);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(path, contents).unwrap();
    }

    #[tokio::test]
    async fn test_write_rejects_empty_description_on_bound_file() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        // Seed a frontmatter-bound file with NO description (the gap).
        seed_file(
            &repo,
            "issues/gap.md",
            "---\nread_only: false\n---\nBody with no description\n",
        );

        // A body-only write must not silently perpetuate the empty description.
        let err = repo.write("issues/gap.md", "updated body").await.unwrap_err();
        assert!(
            err.to_string().contains("no description"),
            "expected loud rejection, got: {err}"
        );

        // Supplying a description heals it and succeeds.
        repo.write(
            "issues/gap.md",
            "---\ndescription: The healed headline\n---\nupdated body\n",
        )
        .await
        .unwrap();
        let healed = parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap()).unwrap();
        assert_eq!(healed.frontmatter.description, "The healed headline");
    }

    #[tokio::test]
    async fn test_write_allows_no_description_on_unbound_schemas() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        // tasks/, journal/, system/dynamic/ legitimately lack description.
        repo.write("tasks/a-todo.md", "todo body").await.unwrap();
        repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap();
        repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap();

        // New bound files still get the auto-generated default on create.
        repo.write("reference/new.md", "some reference").await.unwrap();
        let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap();
        assert!(!f.frontmatter.description.is_empty());
    }

    #[tokio::test]
    async fn test_append_rejects_empty_description_on_bound_file() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        // Existing bound file missing description.
        seed_file(&repo, "projects/gap.md", "---\n---\nInitial body\n");

        let err = repo.append("projects/gap.md", "more").await.unwrap_err();
        assert!(err.to_string().contains("no description"));
    }

    #[tokio::test]
    async fn test_audit_flags_only_bound_files_missing_description() {
        let dir = TempDir::new().unwrap();
        let repo = MemoryRepo::new("test-agent", dir.path());
        repo.init().await.unwrap();

        // Bound + missing description → flagged.
        seed_file(&repo, "issues/missing.md", "---\n---\nbody\n");
        // Bound + has description → not flagged.
        seed_file(
            &repo,
            "reference/has.md",
            "---\ndescription: present\n---\nbody\n",
        );
        // Unbound schema + no description → not flagged (legitimate).
        seed_file(&repo, "tasks/todo.md", "---\nid: t1\n---\nbody\n");
        seed_file(&repo, "journal/2026/05/20.md", "---\n---\nfreeform\n");

        let missing = repo.audit().await.unwrap();
        assert_eq!(missing, vec!["issues/missing.md".to_string()]);
    }
}
