Mega Mind v0.5.4

mind.rs

Rust · 812 lines · 39.7 KB
//! The mind itself: opening and creating it, loading entries, validating and committing.

use super::paths::{named_dir, DATA_MARKERS, DATA_WEAK_MARKERS};
use super::{app_cache_dir, create_private_dir, strip_controls, data_dir, env_setting, expand, kind, sections, secret_check, split_frontmatter,
    valid_id, Config, Entry, Meta, Project, KINDS};
use anyhow::{anyhow, bail, Context, Result};
use fs2::FileExt;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct Mind {
    pub home: PathBuf,
    pub projects: BTreeMap<String, Project>,
    pub config: Config,
}

// No trailing slash on the private folders: `reviews/` matches only a real folder, so a symlink
// named `reviews` could be committed (and replace the folder on pull).
pub const GITIGNORE: &str = "index.db\nindex.db-*\n.models/\nmodels/\n.lock\nreviews\nhook-state\n.auth-cache*\nauth-cache*\n.edit\n.*.tmp-*\n";

impl Mind {
    pub fn home() -> PathBuf {
        env_setting(&["MIND_HOME"])
            .map(PathBuf::from)
            .unwrap_or_else(|| named_dir(&data_dir(), "", DATA_MARKERS, DATA_WEAK_MARKERS))
    }

    pub fn open() -> Result<Mind> {
        let home = Self::home();
        require_git()?;
        if !home.join("entries").is_dir() {
            bail!("no mind at {} — run `mind setup` first", home.display());
        }
        // Minds made by older versions: make sure newer private files stay out of git.
        let gi = home.join(".gitignore");
        let mut gi_changed = false;
        // Never through a link (a pulled `.gitignore` symlink to ~/.bashrc would get our lines).
        let gi_is_file = fs::symlink_metadata(&gi).is_ok_and(|m| m.file_type().is_file() && !hard_linked(&gi, &m));
        if let Some(cur) = gi_is_file.then(|| fs::read_to_string(&gi).ok()).flatten() {
            let missing: Vec<&str> = GITIGNORE.lines().filter(|l| !cur.lines().any(|c| c == *l)).collect();
            if !missing.is_empty() {
                gi_changed = super::write_atomic(&gi, format!("{}\n{}\n", cur.trim_end(), missing.join("\n"))).is_ok();
            }
        }
        // Tokens used to be cached inside the mind folder; they now live in the cache folder.
        // (and on Windows, 0.3.0 previews cached them as auth-cache.json in the same folder)
        let _ = fs::remove_file(home.join(".auth-cache.json"));
        if home != app_cache_dir() {
            let _ = fs::remove_file(home.join("auth-cache.json"));
        }
        guard_private_dirs(&home)?;
        untrack_local_only(&home);
        // Only projects approved on this machine are used; projects.toml keeps them all.
        let projects = approved_projects(&home, read_projects(&home)?);
        let config = match read_plain(&home.join("config.toml")) {
            Ok(s) => {
                let mut c: Config = toml::from_str(&s).map_err(|e| toml_error("config.toml", &s, &e))?;
                c.identity = c.identity.sanitized();
                c.clamp_limits();
                check_ai_approval(&home, &mut c);
                c
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
            Err(e) => bail!("can't read {} ({e}); {READ_FIX}", home.join("config.toml").display()),
        };
        let mind = Mind { home, projects, config };
        if gi_changed {
            // Committed, so doctor doesn't report a hand edit (only this file), under the lock.
            // Never wait for it: a hook opening the mind while a long command holds the lock skips
            // this (the next open commits it).
            let lock = fs::OpenOptions::new().create(true).truncate(false).write(true).open(mind.home.join(".lock"));
            if let Ok(f) = lock
                && f.try_lock_exclusive().is_ok()
            {
                let _ = mind.commit(&[&gi], "gitignore: keep newer private files out of git", "mind");
            }
        }
        Ok(mind)
    }

    pub fn init() -> Result<PathBuf> {
        require_git()?;
        let home = Self::home();
        // The mind holds notes about private systems: only its owner can read it.
        create_private_dir(&home);
        for d in ["entries", "proposals", "reviews", "hook-state"] {
            fs::create_dir_all(home.join(d))?;
        }
        let write_new = |name: &str, content: &str| -> Result<()> { create_if_absent(&home.join(name), content) };
        write_new(".gitignore", GITIGNORE)?;
        write_new("projects.toml", "# Projects the mind knows. Refs are written as `<project>:<path inside it>`.\n")?;
        write_new(
            "config.toml",
            &toml::to_string_pretty(&Config::default())?,
        )?;
        if !home.join(".git").exists() {
            git(&home, &["init", "-q"])?;
            git(&home, &["config", "user.name", "Mega Mind"])?;
            git(&home, &["config", "user.email", "mind@localhost"])?;
            git(&home, &["add", "-A"])?;
            git(&home, &["commit", "-q", "-m", "Mega Mind: initial layout"])?;
        }
        Ok(home)
    }

    pub fn entries_dir(&self) -> PathBuf {
        self.home.join("entries")
    }

    pub fn lock(&self) -> Result<fs::File> {
        let f = match fs::OpenOptions::new().create(true).truncate(false).write(true).open(self.home.join(".lock")) {
            Ok(f) => f,
            // Every write starts here, so say why it can't happen (an agent can't act on a bare
            // "Permission denied"). Searching and reading still work.
            Err(e) if matches!(e.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem) => {
                bail!("{} ({e})", cant_write_help(&self.home))
            }
            Err(e) => return Err(e.into()),
        };
        f.lock_exclusive()?;
        Ok(f)
    }

    pub fn entry_path(&self, id: &str) -> Result<PathBuf> {
        if !valid_id(id) {
            bail!("bad entry id {id:?}");
        }
        Ok(self.entries_dir().join(format!("{id}.md")))
    }

    pub fn load(&self, id: &str) -> Result<Entry> {
        let path = self.entry_path(id)?;
        // A pulled symlink would make us read (index, embed, show) a file outside the mind.
        if fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink() || hard_linked(&path, &m)) {
            bail!("entry {id:?} is a link; entries must be plain files in the mind");
        }
        let raw = fs::read_to_string(&path).map_err(|_| anyhow!("no entry {id:?} (try `mind search`)"))?;
        let (meta, body) = split_frontmatter(&raw).with_context(|| format!("entry {id}"))?;
        Ok(Entry { id: id.to_string(), meta, body, raw })
    }

    /// `projects.toml` as it is on disk now, every project in it, approved here or not (re-read under
    /// the lock before changing it, so two writers don't drop each other's projects).
    pub fn projects_now(&self) -> Result<BTreeMap<String, Project>> {
        read_projects(&self.home)
    }

    /// The ids of the entries: every `entries/*.md` whose name is a valid id. Anything else there
    /// (a README.md, an editor's `x copy.md`) is skipped with a warning, so it can't break every command.
    pub fn all_ids(&self) -> Result<Vec<String>> {
        let mut ids: Vec<String> = fs::read_dir(self.entries_dir())?
            .filter_map(|e| e.ok())
            .filter_map(|e| {
                let p = e.path();
                if p.extension()? != "md" {
                    return None;
                }
                let id = p.file_stem()?.to_string_lossy().to_string();
                if e.file_type().is_ok_and(|t| t.is_symlink()) || fs::symlink_metadata(&p).is_ok_and(|m| hard_linked(&p, &m)) {
                    eprintln!("warning: skipping {} (a link; entries must be plain files)", strip_controls(&p.display().to_string()));
                    return None;
                }
                // An entry is a page of notes; a huge file would make every hook and search slow.
                if e.metadata().is_ok_and(|m| m.len() > MAX_ENTRY_BYTES) {
                    eprintln!("warning: skipping {} (over 1 MB; entries are notes, not files)", strip_controls(&p.display().to_string()));
                    return None;
                }
                if !valid_id(&id) {
                    static WARNED: std::sync::Once = std::sync::Once::new();
                    WARNED.call_once(|| {
                        eprintln!("warning: skipping {} (not an entry name: lowercase letters, digits and dashes)", p.display())
                    });
                    return None;
                }
                Some(id)
            })
            .collect();
        ids.sort();
        Ok(ids)
    }

    /// Project whose path contains `dir` (the deepest match), e.g. the cwd of an agent. A relative
    /// project path (from an older `project add .`) matches nothing: it would mean a different
    /// folder in every process.
    pub fn project_for_dir(&self, dir: &Path) -> Option<String> {
        let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
        self.projects
            .iter()
            .filter_map(|(name, p)| {
                let root = expand(&p.path);
                if !root.is_absolute() {
                    return None;
                }
                let root = root.canonicalize().unwrap_or(root);
                dir.starts_with(&root).then(|| (root.components().count(), name.clone()))
            })
            .max()
            .map(|(_, n)| n)
    }



    /// Validate an entry before it is written.
    pub fn validate(&self, meta: &Meta, body: &str) -> Result<()> {
        let mut errs = vec![];
        if meta.title.trim().is_empty() {
            errs.push("frontmatter needs a `title`".to_string());
        }
        if super::has_conflict_markers(body) {
            errs.push("the entry has git conflict markers (<<<<<<<, =======, >>>>>>>): resolve them first".to_string());
        }
        let k = kind(&meta.kind);
        if k.is_none() {
            errs.push(format!(
                "`kind` must be one of: {}",
                KINDS.iter().map(|k| k.name).collect::<Vec<_>>().join(", ")
            ));
        }
        if meta.project.trim().is_empty() {
            errs.push("frontmatter needs a `project`".into());
        } else if !self.projects.contains_key(&meta.project) && meta.project != "general" {
            errs.push(format!(
                "unknown project {:?}: use one of {} or `general`, or add it with `mind project add`",
                meta.project,
                self.projects.keys().cloned().collect::<Vec<_>>().join(", ")
            ));
        }
        if let Some(k) = k {
            let have: Vec<String> = sections(body).into_iter().map(|(h, _)| h.to_lowercase()).collect();
            for (h, _, req) in k.sections {
                if *req && !have.iter().any(|x| x == &h.to_lowercase()) {
                    errs.push(format!("a `{}` entry needs a `## {h}` section (see `mind template {}`)", k.name, k.name));
                }
            }
        }
        if body.trim().len() < 40 {
            errs.push("the body is nearly empty".into());
        }
        if !errs.is_empty() {
            bail!("{}", errs.join("\n"));
        }
        secret_check(&format!("{}\n{}", serde_yaml::to_string(meta).unwrap_or_default(), body))
    }

    pub fn commit(&self, paths: &[&Path], message: &str, author: &str) -> Result<()> {
        let rel: Vec<String> = paths
            .iter()
            .map(|p| p.strip_prefix(&self.home).unwrap_or(p).to_string_lossy().to_string())
            .collect();
        // A git killed mid-commit leaves .git/index.lock, and every later commit fails. All writers
        // hold the mind lock (`lock()`), so one older than a minute is a leftover, not a live git.
        let git_lock = self.home.join(".git").join("index.lock");
        if fs::metadata(&git_lock).and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|d| d.as_secs() > 60)) {
            eprintln!("warning: removing a leftover {} (from an interrupted git)", git_lock.display());
            let _ = fs::remove_file(&git_lock);
        }
        // A merge or rebase stopped on a conflict blocks every partial commit: say so plainly.
        if let Some(what) = self.unfinished_git() {
            bail!("{}", unfinished_git_help(&self.home, what));
        }
        // `-f`: these are files we just wrote; a pulled .gitignore listing entries/ mustn't block saving.
        let mut add = vec!["add", "-A", "-f", "--"];
        add.extend(rel.iter().map(String::as_str));
        if let Err(e) = git(&self.home, &add) {
            // Codex's sandbox keeps every `.git` read-only, even inside a folder it may write.
            let m = format!("{e:#}");
            if m.contains("Read-only file system") || m.contains("Permission denied") {
                bail!("{}", cant_write_help(&self.home));
            }
            return Err(e);
        }
        // Nothing changed (an `ack` twice in a day, an identical update): nothing to commit, not an error.
        let mut diff = vec!["diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--"];
        diff.extend(rel.iter().map(String::as_str));
        if git(&self.home, &diff).is_ok() {
            return Ok(());
        }
        // Names come from --as / $MIND_AGENT; keep them to characters git accepts in an author.
        let clean: String = author.chars().filter(|c| c.is_alphanumeric() || " ._-".contains(*c)).take(60).collect();
        let author = format!("--author={} <agent@localhost>", if clean.trim().is_empty() { "unknown" } else { clean.trim() });
        let mut c = vec!["commit", "-q", "-m", message, &author, "--"];
        c.extend(rel.iter().map(String::as_str));
        if let Err(e) = git(&self.home, &c) {
            // Unstage what we added, so the index matches HEAD again (the caller puts the file back);
            // else a later `git commit` or `merge --abort` would trip over it.
            let mut reset = vec!["reset", "-q", "--"];
            reset.extend(rel.iter().map(String::as_str));
            let _ = git(&self.home, &reset);
            return Err(e);
        }
        Ok(())
    }

    /// "merge" or "rebase" when the mind's repo is stopped in the middle of one.
    pub fn unfinished_git(&self) -> Option<&'static str> {
        let g = self.home.join(".git");
        if g.join("MERGE_HEAD").exists() {
            Some("merge")
        } else if g.join("rebase-merge").exists() || g.join("rebase-apply").exists() {
            Some("rebase")
        } else if g.join("CHERRY_PICK_HEAD").exists() {
            Some("cherry-pick")
        } else if g.join("REVERT_HEAD").exists() {
            Some("revert")
        } else {
            None
        }
    }
}


/// Run git in the mind's own repo. Commit signing and git hooks are off for it: they come from the
/// user's global config (often enforced on work machines) and would block or fail every mind write.
/// git settings for the mind's repo that keep its content from starting programs or sending data.
const GIT_SAFE: [&str; 12] = [
    "-c", "diff.external=", "-c", "filter.lfs.process=", "-c", "filter.lfs.clean=", "-c", "filter.lfs.smudge=",
    "-c", "filter.lfs.required=false", "-c", "core.pager=cat",
];
/// git's empty tree, used as the attribute source.
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";

/// Entries larger than this are skipped (see `all_ids`).
pub const MAX_ENTRY_BYTES: u64 = 1 << 20;

pub fn git(dir: &Path, args: &[&str]) -> Result<()> {
    let o = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "core.fsmonitor=false"])
        .args(GIT_SAFE)
        // Ignore the mind's own .gitattributes (a pulled `* filter=lfs` would run git-lfs and could
        // upload entries): attributes come from the empty tree. Older git ignores the variable.
        .env("GIT_ATTR_SOURCE", EMPTY_TREE)
        .arg("-c")
        // Outside the repo: a pulled commit could add an executable `<mind>/.no-git-hooks/pre-commit`.
        .arg(format!("core.hooksPath={}", super::app_cache_dir().join("no-hooks").display()))
        .args(args)
        .output()?;
    if !o.status.success() {
        bail!(
            "git {} failed: {}",
            args.first().unwrap_or(&""),
            // git says some things ("nothing to commit") on stdout only.
            [String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim()]
                .into_iter()
                .filter(|s| !s.is_empty())
                .collect::<Vec<_>>()
                .join(" ")
        );
    }
    Ok(())
}

pub fn git_out(dir: &Path, args: &[&str]) -> String {
    Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(["-c", "core.fsmonitor=false"])
        .arg("-c")
        .arg(format!("core.hooksPath={}", super::app_cache_dir().join("no-hooks").display()))
        .args(GIT_SAFE)
        .env("GIT_ATTR_SOURCE", EMPTY_TREE)
        .args(args)
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
        .unwrap_or_default()
}

/// `projects.toml` in `home`: empty if missing, an error if unreadable (UTF-16, permissions), since
/// setup would otherwise save defaults over it.
fn read_projects(home: &Path) -> Result<BTreeMap<String, Project>> {
    match read_plain(&home.join("projects.toml")) {
        Ok(s) => {
            #[derive(Deserialize)]
            struct F {
                #[serde(default)]
                projects: BTreeMap<String, Project>,
            }
            let mut projects = toml::from_str::<F>(&s).map_err(|e| toml_error("projects.toml", &s, &e))?.projects;
            // Names reach agents' context: a quoted TOML key could hold newlines or instructions.
            // Clean the path first (a control or invisible character in front of `\\\\host` would hide it).
            for p in projects.values_mut() {
                p.path = strip_controls(&p.path).replace(['\n', '\r', '\t'], " ").trim().to_string();
            }
            projects.retain(|name, p| {
                let ok = valid_id(name);
                if !ok {
                    eprintln!("warning: skipping project {:?} in projects.toml (names are lowercase letters, digits and dashes)", strip_controls(name));
                } else if !expand(&p.path).is_absolute() {
                    eprintln!("warning: skipping project {name:?} in projects.toml (its path isn't a full path)");
                    return false;
                } else if is_network_path(&p.path) || is_network_path(&expand(&p.path).to_string_lossy()) {
                    // Checked on the text alone, before anything touches the path: on Windows even
                    // looking up `\\\\host\\share` connects to it and sends the login hash.
                    eprintln!("warning: skipping project {name:?} in projects.toml (a network path)");
                    return false;
                } else if inside_mind(home, &p.path) {
                    eprintln!("warning: skipping project {name:?} in projects.toml (its path is inside the mind folder)");
                    return false;
                } else if too_broad(&p.path) {
                    eprintln!("warning: skipping project {name:?} in projects.toml (its path {:?} is {TOO_BROAD})", strip_controls(&p.path));
                    return false;
                }
                ok
            });
            // Shown as the CLI's own lines (`mind who`, `mind projects`): one line, no controls.
            let line = |s: &str| strip_controls(s).split_whitespace().collect::<Vec<_>>().join(" ");
            for p in projects.values_mut() {
                // Printed as the CLI's own lines and in the sentence telling an agent whom to message.
                p.description = line(&p.description).chars().take(200).collect();
                p.session = super::clean_name(&p.session).unwrap_or_default();
                // A path keeps its own spaces; only line breaks and tabs go.
                p.path = strip_controls(&p.path).replace(['\n', '\r', '\t'], " ").trim().to_string();
                p.stack = p.stack.iter().take(12).map(|s| line(s).chars().take(40).collect()).collect();
            }
            Ok(projects)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
        Err(e) => bail!("can't read {} ({e}); {READ_FIX}", home.join("projects.toml").display()),
    }
}

/// Mega Mind keeps its memory in a git repo: say so plainly when git is missing, instead of a bare
/// "No such file or directory" after an entry was already written.
pub fn require_git() -> Result<()> {
    static OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    if *OK.get_or_init(|| Command::new("git").arg("--version").output().is_ok_and(|o| o.status.success())) {
        Ok(())
    } else {
        let how = if cfg!(windows) {
            "`winget install Git.Git`, or https://git-scm.com"
        } else if cfg!(target_os = "macos") {
            "`xcode-select --install` or `brew install git`"
        } else {
            "`sudo apt install git`, `sudo dnf install git`, or your package manager"
        };
        bail!("git isn't installed (Mega Mind keeps its memory in a git repo). Install it ({how}), then try again")
    }
}

/// Where this machine keeps the fingerprint of the AI settings it approved, per mind, outside the
/// mind's git repo (so a pull can't approve itself).
pub(crate) fn ai_approval_path(home: &Path) -> PathBuf {
    use sha2::{Digest, Sha256};
    // The real path, so `~/x` vs its full path (or case on macOS/Windows) is the same mind.
    let real = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
    let key = hex::encode(Sha256::digest(real.to_string_lossy().as_bytes()));
    // Tests never touch the real cache folder.
    let root = if cfg!(test) { std::env::temp_dir().join(format!("mind-test-approved-{}", std::process::id())) } else { app_cache_dir().join("approved") };
    root.join(format!("ai-{}", &key[..16]))
}

/// config.toml is in the mind's git repo, so a pull or another agent can change it, and its AI
/// settings can run a program (`auth = "command:…"`, the LM Studio path) or send a key and entry text
/// to a URL. Changed settings that could do that are held back until `mind setup` approves them. With
/// no record yet, only settings that send nothing anywhere (local or no embeddings, no librarian)
/// are approved by themselves.
pub(crate) fn check_ai_approval(home: &Path, c: &mut Config) {
    let path = ai_approval_path(home);
    let fp = c.ai_fingerprint();
    let held = "no AI settings have been approved for this mind on this machine yet (a new clone, a cleared cache?), \
                so the ones in config.toml are held back: search is keyword-only and the librarian is off. Run `mind setup` to review and approve them";
    match fs::read_to_string(&path) {
        Ok(approved) if approved.trim() == fp => {}
        // Approved under the older fingerprint (0.5.1 and earlier): the same settings, so the
        // approval stands and is rewritten in the current form.
        Ok(approved) if approved.trim() == c.ai_fingerprint_v3() => {
            let _ = approve_ai(home, c);
        }
        // Any change waits for `mind setup`, even to local settings (a pull switching to the local
        // model makes the next search download ~420 MB; thresholds can break `record`).
        Ok(_) => c.hold_ai(
            "the AI settings in config.toml changed since you approved them on this machine (a pull or another agent?), \
             so they're held back: search is keyword-only and the librarian is off. Run `mind setup` to review and approve them"
                .to_string(),
        ),
        // No record. Settings that send nothing anywhere are fine; anything else (a cloned mind,
        // a cleared cache) waits for `mind setup`, which shows it.
        Err(_) if !c.ai_is_risky() => {
            let _ = approve_ai(home, c);
        }
        Err(_) => c.hold_ai(held.to_string()),
    }
}

/// Record `c`'s AI settings as approved on this machine (setup does this when you save them).
pub fn approve_ai(home: &Path, c: &Config) -> std::io::Result<()> {
    let path = ai_approval_path(home);
    if let Some(d) = path.parent() {
        fs::create_dir_all(d)?;
    }
    super::write_atomic(&path, c.ai_fingerprint())
}

/// Why a save failed when the mind folder (or its `.git`) can't be written here, and what to do. An
/// agent can't act on a bare "Permission denied"; this tells it to ask for the one command to run
/// outside its sandbox, which is how Codex and similar tools handle it.
pub fn cant_write_help(home: &Path) -> String {
    format!(
        "this process can't write to the mind folder {}: a sandbox (Codex's blocks writes outside the project \
         and keeps every .git folder read-only) or a read-only folder. Searching and reading still work. To save, \
         run this same command outside the sandbox: in Codex, request escalated permissions for it (the user is \
         asked to approve); otherwise ask the user to run it",
        home.display()
    )
}

/// What to do when a mind file can't be read (a link, too big, not UTF-8): said in the error.
const READ_FIX: &str = "it must be a plain UTF-8 text file in the mind folder (not a symbolic or hard link) and at most 1 MB; fix it and try again";
/// What `too_broad` refuses, in words.
pub const TOO_BROAD: &str = "too broad for a project: a whole drive, your home folder, a personal folder such as Documents or OneDrive, a settings or system folder, or a network share";

/// A project path that would let refs reach everything: a filesystem root or the home folder itself.
pub fn too_broad(path: &str) -> bool {
    // Network shares and device paths (`\\\\host\\share`, `//host/x`, `\\\\?\\…`): opening one on Windows
    // sends the user's login hash to that host.
    if is_network_path(path) {
        return true;
    }
    let p = expand(path);
    let p = p.canonicalize().unwrap_or(p);
    let home = super::home_dir();
    let home = home.canonicalize().unwrap_or(home);
    // A root, the home folder or anything above it (`/home`, `/Users`), or a folder of secrets.
    const SECRET_DIRS: [&str; 8] = [".ssh", ".aws", ".gnupg", ".azure", ".kube", ".docker", ".password-store", ".config"];
    // Hidden folders right under home hold tool settings and credentials (~/.claude, ~/.codex, …).
    // …and the per-user settings and credential stores of Windows and macOS.
    let first = p.strip_prefix(&home).ok().and_then(|r| r.components().next()).map(|c| c.as_os_str().to_string_lossy().to_lowercase());
    let hidden_in_home = first.as_deref().is_some_and(|c| c.starts_with('.') || c == "appdata" || c == "library");
    const SYSTEM: [&str; 7] = ["/etc", "/proc", "/sys", "/dev", "/boot", "/root", "/var/lib"];
    // The personal folders themselves (a project inside one is fine): everything a user keeps.
    const PERSONAL: [&str; 7] = ["documents", "desktop", "downloads", "pictures", "music", "videos", "onedrive"];
    let name = |q: &Path| q.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
    // OneDrive's own folder is `OneDrive` or `OneDrive - <Company>`, and it can hold the redirected
    // Documents, Desktop and Pictures (`~/OneDrive/Documents`).
    let onedrive = |q: &Path| q.parent() == Some(home.as_path()) && (name(q) == "onedrive" || name(q).starts_with("onedrive - "));
    let personal = (p.parent() == Some(home.as_path()) && (PERSONAL.contains(&name(&p).as_str()) || onedrive(&p)))
        || (p.parent().is_some_and(onedrive) && PERSONAL.contains(&name(&p).as_str()));
    p.parent().is_none()
        || home.starts_with(&p)
        || hidden_in_home
        || personal
        || SECRET_DIRS.iter().any(|d| p.starts_with(home.join(d)))
        || (cfg!(unix) && SYSTEM.iter().any(|d| p.starts_with(d)))
}

/// The mind's own folders must be real folders inside it. A pulled commit can put a symlink where
/// an ignored folder is (`reviews -> ~/.claude`), and the nightly upkeep would then write review
/// files into another program's settings. Ignored, local-only ones (and the index) are replaced
/// with a real folder (removing a link never touches its target); tracked ones are refused.
pub(crate) fn guard_private_dirs(home: &Path) -> Result<()> {
    let is_link = |p: &Path| fs::symlink_metadata(p).is_ok_and(|m| is_link_meta(&m));
    for d in ["entries", "proposals"] {
        if is_link(&home.join(d)) {
            bail!("{} is a symbolic link; the mind's {d} folder must be a real folder inside it", home.join(d).display());
        }
    }
    // Something of the wrong kind at one of our names is moved aside, never deleted.
    let move_aside = |p: &Path| -> Result<()> {
        let to = p.with_file_name(format!("{}.moved-aside-{}", p.file_name().unwrap_or_default().to_string_lossy(), std::process::id()));
        eprintln!("warning: {} wasn't what Mega Mind keeps there (from a pull?); moved to {}", strip_controls(&p.display().to_string()), to.display());
        fs::rename(p, &to).with_context(|| format!("moving {} aside", p.display()))
    };
    for d in ["reviews", "hook-state", ".edit"] {
        let p = home.join(d);
        if is_link(&p) {
            eprintln!("warning: {} was a symbolic link (from a pull?); replaced with an empty folder", p.display());
            fs::remove_file(&p)?;
        } else if p.exists() && !p.is_dir() {
            move_aside(&p)?;
        }
        if d != ".edit" {
            fs::create_dir_all(&p)?;
        }
        // Links (or anything but plain files) inside them: a pulled `reviews/x.md -> ~/.bashrc`
        // would be written through by the nightly upkeep. They are local-only, so remove the link.
        for e in fs::read_dir(&p).into_iter().flatten().flatten() {
            if fs::symlink_metadata(e.path()).is_ok_and(|m| is_link_meta(&m)) {
                eprintln!("warning: removed the symbolic link {} (from a pull?)", strip_controls(&e.path().display().to_string()));
                fs::remove_file(e.path())?;
            }
        }
    }
    for f in ["index.db", "index.db-wal", "index.db-shm", ".lock"] {
        let p = home.join(f);
        if is_link(&p) {
            eprintln!("warning: {} was a symbolic link (from a pull?); removed", p.display());
            fs::remove_file(&p)?;
        } else if p.is_dir() {
            move_aside(&p)?;
        }
    }
    Ok(())
}

/// How to finish or undo a stopped merge/rebase/cherry-pick/revert, with the mind's path quoted.
pub fn unfinished_git_help(home: &Path, what: &str) -> String {
    let dir = format!("\"{}\"", home.display());
    let finish = if what == "merge" { "commit".to_string() } else { format!("{what} --continue") };
    format!(
        "the mind's git repo is in the middle of a {what} (from a pull?), so nothing can be saved. Resolve the conflicts \
         and finish it (`git -C {dir} add -A` then `git -C {dir} {finish}`), or undo it (`git -C {dir} {what} --abort`), then try again"
    )
}

/// A mind file's text, only if it is a plain file in the mind (a pulled symlink would read, and
/// then print in errors, a file from anywhere: `projects.toml -> ~/.aws/credentials`).
pub fn read_plain(path: &Path) -> std::io::Result<String> {
    let m = fs::symlink_metadata(path)?;
    if !m.file_type().is_file() || hard_linked(path, &m) {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "not a plain file (a symbolic or hard link?)"));
    }
    if m.len() > MAX_ENTRY_BYTES {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "larger than 1 MB"));
    }
    fs::read_to_string(path)
}

/// A TOML error without the file's text: toml's own message quotes the offending line, which could
/// be anything a pull put there (instructions, or another file's contents), and errors reach agents.
pub fn toml_error(name: &str, src: &str, e: &toml::de::Error) -> anyhow::Error {
    let line = e.span().map(|s| src[..s.start.min(src.len())].matches('\n').count() + 1);
    let what = e.message().split(['"', '`', '\'']).step_by(2).collect::<Vec<_>>().join("…");
    let what: String = strip_controls(&what).chars().take(80).collect();
    anyhow!("{name}: {}{} (fix it, then `mind doctor`)", line.map(|l| format!("line {l}: ")).unwrap_or_default(), what.replace('\n', " "))
}

/// Whether a project path is the mind folder or inside it (a repo planted in a synced mind must
/// never be run git in).
pub fn inside_mind(home: &Path, path: &str) -> bool {
    let mind = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
    let p = expand(path);
    let p = p.canonicalize().unwrap_or(p);
    p.starts_with(&mind)
}

/// The local-only files (reviews, hook state, the index) are never meant to be in git; a pull can
/// still bring them (`git add -f`), as fake librarian verdicts or a poisoned index. Tracked copies are
/// deleted and untracked (the user's own copies are untracked, so they're never touched).
fn untrack_local_only(home: &Path) {
    if !home.join(".git").exists() {
        return;
    }
    const LOCAL: [&str; 7] = ["reviews", "hook-state", ".edit", "index.db", "index.db-wal", "index.db-shm", ".lock"];
    let mut ls = vec!["ls-files", "-z", "--"];
    ls.extend(LOCAL);
    let tracked = git_out(home, &ls);
    let paths: Vec<&str> = tracked.split('\0').filter(|p| !p.is_empty()).collect();
    if paths.is_empty() {
        return;
    }
    eprintln!("warning: removing {} file(s) a commit put in the mind's local-only folders (reviews, index…)", paths.len());
    for p in &paths {
        let full = home.join(p);
        if full.starts_with(home) && fs::symlink_metadata(&full).is_ok_and(|m| !m.is_dir()) {
            let _ = fs::remove_file(&full);
        }
    }
    let mut rm = vec!["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--"];
    rm.extend(LOCAL);
    if git(home, &rm).is_ok() {
        let mut c = vec!["commit", "-q", "-m", "untrack local-only files (reviews, index) that a commit added", "--author=mind <agent@localhost>", "--"];
        c.extend(paths.iter().copied());
        let _ = git(home, &c);
    }
}

#[cfg(test)]
pub(crate) fn untrack_local_only_for_test(home: &Path) {
    untrack_local_only(home)
}

/// A symbolic link, or on Windows any reparse point (a junction isn't `is_symlink`).
fn is_link_meta(m: &fs::Metadata) -> bool {
    // Rust reports name-surrogate reparse points (symlinks, junctions) as symlinks on Windows;
    // other reparse points (OneDrive placeholders) are ordinary files and must not be removed.
    m.file_type().is_symlink()
}

/// A file with more than one name (a hard link): writing it changes the other name too, and
/// reading it may read a file from outside the mind.
fn hard_linked(path: &Path, m: &fs::Metadata) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        let _ = path;
        m.nlink() > 1
    }
    #[cfg(windows)]
    {
        // std's link count is unstable on Windows: ask the file itself. Opened for no access (just
        // its attributes), sharing everything, never through a link, so nothing is locked or read.
        use std::os::windows::fs::OpenOptionsExt;
        use std::os::windows::io::AsRawHandle;
        use windows_sys::Win32::Storage::FileSystem::{
            GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
            FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
        };
        if !m.is_file() {
            return false;
        }
        let Ok(f) = fs::OpenOptions::new()
            .access_mode(0)
            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
            .open(path)
        else {
            return false;
        };
        let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
        // SAFETY: a valid open handle and a zeroed struct of the right type for the call to fill.
        let ok = unsafe { GetFileInformationByHandle(f.as_raw_handle() as _, &mut info) } != 0;
        ok && info.nNumberOfLinks > 1
    }
    #[cfg(not(any(unix, windows)))]
    {
        let _ = (path, m);
        false
    }
}

fn project_approval_path(home: &Path) -> PathBuf {
    ai_approval_path(home).with_file_name(format!(
        "projects-{}",
        ai_approval_path(home).file_name().unwrap_or_default().to_string_lossy().trim_start_matches("ai-")
    ))
}

/// The `name\tpath` lines approved on this machine, or None if there is no record yet.
fn approved_project_lines(home: &Path) -> Option<std::collections::BTreeSet<String>> {
    fs::read_to_string(project_approval_path(home)).ok().map(|s| s.lines().map(str::to_string).collect())
}

/// Projects whose name and path this machine approved. A project only points agents at folders on
/// this machine, so one that arrived through a pull (a synced mind) waits for `mind setup` or
/// `mind project add`. With no record yet, a mind that syncs nowhere (no git remote) approves the
/// projects it has; a synced one approves none.
fn approved_projects(home: &Path, all: BTreeMap<String, Project>) -> BTreeMap<String, Project> {
    let line = |n: &str, p: &Project| format!("{n}\t{}", p.path);
    let approved = match approved_project_lines(home) {
        Some(a) => a,
        None => {
            let synced = home.join(".git").exists() && !git_out(home, &["remote"]).trim().is_empty();
            if !synced {
                let _ = approve_projects(home, &all);
                return all;
            }
            Default::default()
        }
    };
    let (ok, held): (BTreeMap<_, _>, BTreeMap<_, _>) = all.into_iter().partition(|(n, p)| approved.contains(&line(n, p)));
    if !held.is_empty() {
        static WARNED: std::sync::Once = std::sync::Once::new();
        WARNED.call_once(|| {
            eprintln!(
                "warning: {} project(s) in projects.toml aren't approved on this machine (from a pull?), so they're not used: {}. \
                 Run `mind setup` to review them, or `mind project add <name> <folder>`",
                held.len(),
                held.keys().cloned().collect::<Vec<_>>().join(", ")
            )
        });
    }
    ok
}

/// The projects in projects.toml this machine hasn't approved.
pub fn held_projects(home: &Path) -> BTreeMap<String, Project> {
    let all = read_projects(home).unwrap_or_default();
    let ok = approved_projects(home, all.clone());
    all.into_iter().filter(|(n, _)| !ok.contains_key(n)).collect()
}

/// Approve these projects (name and path) on this machine, keeping earlier approvals.
pub fn approve_projects(home: &Path, projects: &BTreeMap<String, Project>) -> std::io::Result<()> {
    let mut lines = approved_project_lines(home).unwrap_or_default();
    for (n, p) in projects {
        lines.insert(format!("{n}\t{}", p.path));
    }
    let path = project_approval_path(home);
    if let Some(d) = path.parent() {
        fs::create_dir_all(d)?;
    }
    super::write_atomic(&path, lines.into_iter().collect::<Vec<_>>().join("\n") + "\n")
}

/// A network share, device path or automount path, judged on the text only (never resolve it
/// first): two leading slashes in either direction (`\\\\host`, `//host`, `\\/host`, `/\\host`),
/// `\\\\?\\`, `\\??\\`, and macOS `/net/…`.
pub fn is_network_path(path: &str) -> bool {
    let t = path.trim();
    let b = t.as_bytes();
    let slash = |c: u8| c == b'/' || c == b'\\';
    (b.len() >= 2 && slash(b[0]) && slash(b[1])) || t.starts_with("\\??\\") || t.starts_with("/net/") || t == "/net"
}

/// Write `content` to `path` only if nothing is there, links included: a dangling link from a pull
/// (`.gitignore -> ~/.ssh/config`) reports "doesn't exist", and writing would follow it.
pub(crate) fn create_if_absent(path: &Path, content: &str) -> Result<()> {
    if fs::symlink_metadata(path).is_ok() {
        return Ok(());
    }
    use std::io::Write;
    match fs::OpenOptions::new().write(true).create_new(true).open(path) {
        Ok(mut f) => f.write_all(content.as_bytes())?,
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
        Err(e) => return Err(e.into()),
    }
    Ok(())
}