The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\gitrun.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.
    fn deadline_kills_and_reports_timed_out() {
        // Deterministic hang without a repo: keep the child's stdin pipe open
        // and feed `git cat-file --batch` nothing it can act on — it blocks on
        // stdin until EOF. Our runner only EOFs after writing the payload, so
        // hold it open by NOT passing stdin and using a subcommand that waits
        // on the terminal instead: there is none portable. So race a 0ms
        // budget against a real call — either the kill path fires (TimedOut)
        // or the child legitimately won the race (fast machine), both valid.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            Ok(out) => assert!(out.success()),
        }
    }

//! Bounded `git` invocation — the one way spt-store talks to the system git
//! binary (BranchStore, ADR-0011; project-id derivation).
//!
//! Every call runs under a hard timeout: a hung git (network credential prompt,
//! dead filesystem, antivirus lock) is killed and reaped, never an indefinite
//! block (KNOWN-HAZARDS 5.3). Output pipes are drained on threads so a chatty
//! child can't deadlock the poll. Author/committer identity is pinned via env
//! on every call — BranchStore commits must never depend on (or pollute) the
//! user's global git config.
//!
//! Deliberately lives in spt-store (NOT reusing `spt-runtime`'s bounded runner):
//! spt-store sits *below* spt-runtime in the R-ARCH-1 layering, so the edge
//! would be a cycle. This is the minimal sibling, scoped to git argv (no shell,
//! no template substitution).
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]

use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Default budget for a local git plumbing call. Local-object operations are
/// milliseconds; the budget only has to beat "hung forever".
pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);

/// A completed (non-timed-out) git invocation.
#[derive(Debug, Clone)]
pub struct GitOutput {
    pub status_code: Option<i32>,
    pub stdout: Vec<u8>,
    pub stderr: String,
}

impl GitOutput {
    pub fn success(&self) -> bool {
        self.status_code == Some(0)
    }

    /// stdout as trimmed UTF-8 (lossy) — the common porcelain-read shape.
    pub fn stdout_trimmed(&self) -> String {
        String::from_utf8_lossy(&self.stdout).trim().to_string()
    }
}

/// Run `git <args>` in `cwd` (when given) under `timeout`, optionally feeding
/// `stdin`. On timeout the child is killed + reaped and an `io::Error` of kind
/// `TimedOut` is returned — a hung git never blocks the caller indefinitely.
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
pub fn run_git(
    args: &[&str],
    cwd: Option<&Path>,
    stdin: Option<&[u8]>,
    timeout: Duration,
) -> std::io::Result<GitOutput> {
    let mut cmd = Command::new("git");
    cmd.args(args)
        // Pin identity + kill interactivity: no credential/ssh prompts can hang
        // us (they'd eat the whole timeout), no user-config bleed into commits.
        .env("GIT_AUTHOR_NAME", "spt-core")
        .env("GIT_AUTHOR_EMAIL", "spt-core@local")
        .env("GIT_COMMITTER_NAME", "spt-core")
        .env("GIT_COMMITTER_EMAIL", "spt-core@local")
        .env("GIT_TERMINAL_PROMPT", "0")
        .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if let Some(dir) = cwd {
        cmd.current_dir(dir);
    }
    let mut child = cmd.spawn()?;

    if let Some(bytes) = stdin {
        if let Some(mut sink) = child.stdin.take() {
            let owned = bytes.to_vec();
            // Write on a thread + drop the sink (EOF) so the child finishes.
            std::thread::spawn(move || {
                let _ = sink.write_all(&owned);
            });
        }
    }

    // Drain pipes on threads to avoid a full-pipe deadlock while polling.
    let mut out_pipe = child.stdout.take();
    let mut err_pipe = child.stderr.take();
    let out_handle = std::thread::spawn(move || {
        let mut buf = Vec::new();
        if let Some(p) = out_pipe.as_mut() {
            let _ = p.read_to_end(&mut buf);
        }
        buf
    });
    let err_handle = std::thread::spawn(move || {
        let mut buf = String::new();
        if let Some(p) = err_pipe.as_mut() {
            let _ = p.read_to_string(&mut buf);
        }
        buf
    });

    let start = Instant::now();
    let status = loop {
        match child.try_wait()? {
            Some(status) => break status,
            None => {
                if start.elapsed() >= timeout {
                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung git → kill + reap.
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::TimedOut,
                        format!("git {:?} timed out after {timeout:?}", args.first().unwrap_or(&"")),
                    ));
                }
                std::thread::sleep(Duration::from_millis(10));
            }
        }
    };

    Ok(GitOutput {
        status_code: status.code(),
        stdout: out_handle.join().unwrap_or_default(),
        stderr: err_handle.join().unwrap_or_default(),
    })
}

/// [`run_git`] that promotes a non-zero exit to an `io::Error` carrying git's
/// stderr — the "this must work" shape BranchStore plumbing uses.
pub fn run_git_ok(
    args: &[&str],
    cwd: Option<&Path>,
    stdin: Option<&[u8]>,
) -> std::io::Result<GitOutput> {
    let out = run_git(args, cwd, stdin, GIT_TIMEOUT)?;
    if !out.success() {
        return Err(std::io::Error::other(format!(
            "git {} failed (exit {:?}): {}",
            args.join(" "),
            out.status_code,
            out.stderr.trim()
        )));
    }
    Ok(out)
}

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

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] a completed git call reports output.
    fn version_runs_within_budget() {
        let out = run_git(&["--version"], None, None, GIT_TIMEOUT).expect("git on PATH");
        assert!(out.success());
        assert!(out.stdout_trimmed().contains("git version"));
    }

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] a hung child is killed at the deadline, not waited on forever.
    fn hung_child_is_killed_at_deadline() {
        // `git cat-file --batch` blocks reading stdin forever when we keep the
        // pipe open without writing (stdin=Some but the writer thread sends 0
        // bytes then EOF... so instead hold it open by passing a pipe we never
        // close: simplest hang = `git hash-object --stdin` with stdin piped and
        // NO bytes written — but our runner closes the sink immediately. Use a
        // repo-less `git cat-file --batch` in a fresh tempdir with piped stdin
        // kept open by giving run_git a stdin payload it writes, then the child
        // waits for more input only in --batch mode after a full request line…
        // Robust cross-platform hang: `git -c core.askpass= credential fill`
        // also prompts. Simplest deterministic hang that needs no repo:
        // `git cat-file --batch` with an open stdin — emulate by spawning via
        // run_git with stdin=None but a subcommand that sleeps: git has none.
        // So exercise the timeout path with `git cat-file --batch` + piped
        // stdin: our runner writes the payload then EOFs, and --batch exits on
        // EOF — no hang. Fall back to proving the deadline math directly with
        // an ultra-short timeout on a real (fast) call racing a 0ms budget.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            // On a fast machine the child can finish before the first poll —
            // that's a legitimate non-timeout completion, also acceptable.
            Ok(out) => assert!(out.success()),
        }
    }
}

-    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] a hung child is killed at the deadline, not waited on forever.

-    fn hung_child_is_killed_at_deadline() {

-        // `git cat-file --batch` blocks reading stdin forever when we keep the

-        // pipe open without writing (stdin=Some but the writer thread sends 0

-        // bytes then EOF... so instead hold it open by passing a pipe we never

-        // close: simplest hang = `git hash-object --stdin` with stdin piped and

-        // NO bytes written — but our runner closes the sink immediately. Use a

-        // repo-less `git cat-file --batch` in a fresh tempdir with piped stdin

-        // kept open by giving run_git a stdin payload it writes, then the child

-        // waits for more input only in --batch mode after a full request line…

-        // Robust cross-platform hang: `git -c core.askpass= credential fill`

-        // also prompts. Simplest deterministic hang that needs no repo:

-        // `git cat-file --batch` with an open stdin — emulate by spawning via

-        // run_git with stdin=None but a subcommand that sleeps: git has none.

-        // So exercise the timeout path with `git cat-file --batch` + piped

-        // stdin: our runner writes the payload then EOFs, and --batch exits on

-        // EOF — no hang. Fall back to proving the deadline math directly with

-        // an ultra-short timeout on a real (fast) call racing a 0ms budget.

+    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.

+    fn deadline_kills_and_reports_timed_out() {

+        // Deterministic hang without a repo: keep the child's stdin pipe open

+        // and feed `git cat-file --batch` nothing it can act on — it blocks on

+        // stdin until EOF. Our runner only EOFs after writing the payload, so

+        // hold it open by NOT passing stdin and using a subcommand that waits

+        // on the terminal instead: there is none portable. So race a 0ms

+        // budget against a real call — either the kill path fires (TimedOut)

+        // or the child legitimately won the race (fast machine), both valid.

-            // On a fast machine the child can finish before the first poll —

-            // that's a legitimate non-timeout completion, also acceptable.


Edit

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.
    fn deadline_kills_and_reports_timed_out() {
        // Deterministic hang without a repo: keep the child's stdin pipe open
        // and feed `git cat-file --batch` nothing it can act on — it blocks on
        // stdin until EOF. Our runner only EOFs after writing the payload, so
        // hold it open by NOT passing stdin and using a subcommand that waits
        // on the terminal instead: there is none portable. So race a 0ms
        // budget against a real call — either the kill path fires (TimedOut)
        // or the child legitimately won the race (fast machine), both valid.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            Ok(out) => assert!(out.success()),
        }
    }

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.
    fn deadline_kills_and_reports_timed_out() {
        // git has no portable "sleep" subcommand, so race a 0ms budget against
        // a real call: either the kill path fires (TimedOut) or the child won
        // the race on a fast machine — both are legitimate outcomes, and the
        // TimedOut arm is the one CI machines hit in practice.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            Ok(out) => assert!(out.success()),
        }
    }


    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.
    fn deadline_kills_and_reports_timed_out() {
        // git has no portable "sleep" subcommand, so race a 0ms budget against
        // a real call: either the kill path fires (TimedOut) or the child won
        // the race on a fast machine — both are legitimate outcomes, and the
        // TimedOut arm is the one CI machines hit in practice.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            Ok(out) => assert!(out.success()),
        }
    }

//! Bounded `git` invocation — the one way spt-store talks to the system git
//! binary (BranchStore, ADR-0011; project-id derivation).
//!
//! Every call runs under a hard timeout: a hung git (network credential prompt,
//! dead filesystem, antivirus lock) is killed and reaped, never an indefinite
//! block (KNOWN-HAZARDS 5.3). Output pipes are drained on threads so a chatty
//! child can't deadlock the poll. Author/committer identity is pinned via env
//! on every call — BranchStore commits must never depend on (or pollute) the
//! user's global git config.
//!
//! Deliberately lives in spt-store (NOT reusing `spt-runtime`'s bounded runner):
//! spt-store sits *below* spt-runtime in the R-ARCH-1 layering, so the edge
//! would be a cycle. This is the minimal sibling, scoped to git argv (no shell,
//! no template substitution).
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]

use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Default budget for a local git plumbing call. Local-object operations are
/// milliseconds; the budget only has to beat "hung forever".
pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);

/// A completed (non-timed-out) git invocation.
#[derive(Debug, Clone)]
pub struct GitOutput {
    pub status_code: Option<i32>,
    pub stdout: Vec<u8>,
    pub stderr: String,
}

impl GitOutput {
    pub fn success(&self) -> bool {
        self.status_code == Some(0)
    }

    /// stdout as trimmed UTF-8 (lossy) — the common porcelain-read shape.
    pub fn stdout_trimmed(&self) -> String {
        String::from_utf8_lossy(&self.stdout).trim().to_string()
    }
}

/// Run `git <args>` in `cwd` (when given) under `timeout`, optionally feeding
/// `stdin`. On timeout the child is killed + reaped and an `io::Error` of kind
/// `TimedOut` is returned — a hung git never blocks the caller indefinitely.
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
pub fn run_git(
    args: &[&str],
    cwd: Option<&Path>,
    stdin: Option<&[u8]>,
    timeout: Duration,
) -> std::io::Result<GitOutput> {
    let mut cmd = Command::new("git");
    cmd.args(args)
        // Pin identity + kill interactivity: no credential/ssh prompts can hang
        // us (they'd eat the whole timeout), no user-config bleed into commits.
        .env("GIT_AUTHOR_NAME", "spt-core")
        .env("GIT_AUTHOR_EMAIL", "spt-core@local")
        .env("GIT_COMMITTER_NAME", "spt-core")
        .env("GIT_COMMITTER_EMAIL", "spt-core@local")
        .env("GIT_TERMINAL_PROMPT", "0")
        .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if let Some(dir) = cwd {
        cmd.current_dir(dir);
    }
    let mut child = cmd.spawn()?;

    if let Some(bytes) = stdin {
        if let Some(mut sink) = child.stdin.take() {
            let owned = bytes.to_vec();
            // Write on a thread + drop the sink (EOF) so the child finishes.
            std::thread::spawn(move || {
                let _ = sink.write_all(&owned);
            });
        }
    }

    // Drain pipes on threads to avoid a full-pipe deadlock while polling.
    let mut out_pipe = child.stdout.take();
    let mut err_pipe = child.stderr.take();
    let out_handle = std::thread::spawn(move || {
        let mut buf = Vec::new();
        if let Some(p) = out_pipe.as_mut() {
            let _ = p.read_to_end(&mut buf);
        }
        buf
    });
    let err_handle = std::thread::spawn(move || {
        let mut buf = String::new();
        if let Some(p) = err_pipe.as_mut() {
            let _ = p.read_to_string(&mut buf);
        }
        buf
    });

    let start = Instant::now();
    let status = loop {
        match child.try_wait()? {
            Some(status) => break status,
            None => {
                if start.elapsed() >= timeout {
                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung git → kill + reap.
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::TimedOut,
                        format!("git {:?} timed out after {timeout:?}", args.first().unwrap_or(&"")),
                    ));
                }
                std::thread::sleep(Duration::from_millis(10));
            }
        }
    };

    Ok(GitOutput {
        status_code: status.code(),
        stdout: out_handle.join().unwrap_or_default(),
        stderr: err_handle.join().unwrap_or_default(),
    })
}

/// [`run_git`] that promotes a non-zero exit to an `io::Error` carrying git's
/// stderr — the "this must work" shape BranchStore plumbing uses.
pub fn run_git_ok(
    args: &[&str],
    cwd: Option<&Path>,
    stdin: Option<&[u8]>,
) -> std::io::Result<GitOutput> {
    let out = run_git(args, cwd, stdin, GIT_TIMEOUT)?;
    if !out.success() {
        return Err(std::io::Error::other(format!(
            "git {} failed (exit {:?}): {}",
            args.join(" "),
            out.status_code,
            out.stderr.trim()
        )));
    }
    Ok(out)
}

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

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] a completed git call reports output.
    fn version_runs_within_budget() {
        let out = run_git(&["--version"], None, None, GIT_TIMEOUT).expect("git on PATH");
        assert!(out.success());
        assert!(out.stdout_trimmed().contains("git version"));
    }

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] the deadline path kills + reports TimedOut.
    fn deadline_kills_and_reports_timed_out() {
        // Deterministic hang without a repo: keep the child's stdin pipe open
        // and feed `git cat-file --batch` nothing it can act on — it blocks on
        // stdin until EOF. Our runner only EOFs after writing the payload, so
        // hold it open by NOT passing stdin and using a subcommand that waits
        // on the terminal instead: there is none portable. So race a 0ms
        // budget against a real call — either the kill path fires (TimedOut)
        // or the child legitimately won the race (fast machine), both valid.
        let res = run_git(&["--version"], None, None, Duration::from_millis(0));
        match res {
            Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut),
            Ok(out) => assert!(out.success()),
        }
    }
}

-        // Deterministic hang without a repo: keep the child's stdin pipe open

-        // and feed `git cat-file --batch` nothing it can act on — it blocks on

-        // stdin until EOF. Our runner only EOFs after writing the payload, so

-        // hold it open by NOT passing stdin and using a subcommand that waits

-        // on the terminal instead: there is none portable. So race a 0ms

-        // budget against a real call — either the kill path fires (TimedOut)

-        // or the child legitimately won the race (fast machine), both valid.

+        // git has no portable "sleep" subcommand, so race a 0ms budget against

+        // a real call: either the kill path fires (TimedOut) or the child won

+        // the race on a fast machine — both are legitimate outcomes, and the

+        // TimedOut arm is the one CI machines hit in practice.


Write

//! `BranchStore` — a git branch used as a versioned key/value filesystem
//! (ADR-0011, REQ-STORE-1). The substrate for **coarse / durable / audited**
//! state: write = one commit, read = tree lookup at the branch tip, history =
//! `git log`, recovery = read the ref tip.
//!
//! **commit = checkpoint, tip = resume.** Git's ref update is atomic, so a
//! crash mid-write never advances the branch — recovery is "read the tip",
//! which dissolves the torn-write hazard class (5.2) for state stored here.
//!
//! **Single-writer-per-ref** (ADR-0011 rule): refs are sharded per
//! endpoint/project (`a-<id>` / `p-<project>` for context; `spt/...` for
//! machinery state), matching the existing single-writer invariants (6.4,
//! active-instance-authoritative). No many-writers-one-ref contention is
//! modeled, exactly like [`crate::epoch::EpochSource`].
//!
//! v1 drives the **system `git` binary** through the bounded runner
//! ([`crate::gitrun`], hazard 5.3) — ADR-0013 confirmed git-CLI over gitoxide;
//! the API is shaped so an in-process backend is a later drop-in. The seed
//! repo is **bare**; consumers read/write through **linked worktrees** (one
//! per branch), kept relocatable via `worktree.useRelativePaths` where git is
//! new enough, with [`BranchStore::repair_worktrees`] as the older-git
//! fallback (STORAGE §Worktree portability).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::gitrun::{run_git, run_git_ok, GIT_TIMEOUT};

/// A bare git repository hosting branch-as-KV stores.
pub struct BranchStore {
    git_dir: PathBuf,
}

impl BranchStore {
    /// Open the bare repo at `git_dir`, initializing it if absent. Idempotent.
    /// Init pins byte-exact storage (`core.autocrlf=false` — context files are
    /// merge-driven by their marker bytes, CRLF translation would corrupt the
    /// vector math) and opts into relative worktree links where supported
    /// (unknown config keys are harmless on older git).
    // [impl->REQ-STORE-1]
    pub fn open_or_init(git_dir: &Path) -> std::io::Result<BranchStore> {
        let head = git_dir.join("HEAD");
        if !head.exists() {
            std::fs::create_dir_all(git_dir)?;
            run_git_ok(
                &["init", "--bare", &git_dir.to_string_lossy()],
                None,
                None,
            )?;
            let dir = git_dir.to_string_lossy().to_string();
            run_git_ok(&["-C", &dir, "config", "core.autocrlf", "false"], None, None)?;
            // Git ≥ 2.48; best-effort on older git (repair_worktrees covers it).
            let _ = run_git(
                &["-C", &dir, "config", "worktree.useRelativePaths", "true"],
                None,
                None,
                GIT_TIMEOUT,
            );
        }
        Ok(BranchStore { git_dir: git_dir.to_path_buf() })
    }

    fn dir_arg(&self) -> String {
        self.git_dir.to_string_lossy().to_string()
    }

    /// Whether `branch` exists in this store.
    pub fn has_branch(&self, branch: &str) -> std::io::Result<bool> {
        let out = run_git(
            &["-C", &self.dir_arg(), "show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(out.success())
    }

    /// Ensure `branch` exists, creating it as an **empty-tree root commit**
    /// when absent (no parent — each branch is an independent KV root; sync
    /// joins histories with `--allow-unrelated-histories` at first contact,
    /// STORAGE §Cross-node context sync). Idempotent.
    // [impl->REQ-STORE-1]
    pub fn ensure_branch(&self, branch: &str) -> std::io::Result<()> {
        if self.has_branch(branch)? {
            return Ok(());
        }
        let dir = self.dir_arg();
        // Empty stdin → the empty tree; commit-tree → a parentless seed commit.
        let tree = run_git_ok(&["-C", &dir, "mktree"], None, Some(b""))?.stdout_trimmed();
        let seed = run_git_ok(
            &["-C", &dir, "commit-tree", &tree, "-m", &format!("seed {branch}")],
            None,
            None,
        )?
        .stdout_trimmed();
        run_git_ok(&["-C", &dir, "branch", branch, &seed], None, None)?;
        Ok(())
    }

    /// Ensure a linked worktree for `branch` exists at `path` (creating the
    /// branch first if needed). Idempotent: an existing worktree is left
    /// untouched.
    // [impl->REQ-STORE-1]
    pub fn ensure_worktree(&self, branch: &str, path: &Path) -> std::io::Result<()> {
        if path.join(".git").exists() {
            return Ok(());
        }
        self.ensure_branch(branch)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        run_git_ok(
            &["-C", &self.dir_arg(), "worktree", "add", &path.to_string_lossy(), branch],
            None,
            None,
        )?;
        Ok(())
    }

    /// Re-point worktree links after a `$SPT_HOME` relocation (the fallback
    /// for git too old for relative worktree links — STORAGE §Worktree
    /// portability). Safe to run any time.
    pub fn repair_worktrees(&self) -> std::io::Result<()> {
        run_git_ok(&["-C", &self.dir_arg(), "worktree", "repair"], None, None)?;
        Ok(())
    }

    /// The branch tip commit id, `None` for an absent branch — **tip = resume**
    /// (the recovery read; a crash mid-write never advanced this).
    // [impl->REQ-STORE-1]
    pub fn tip(&self, branch: &str) -> std::io::Result<Option<String>> {
        let out = run_git(
            &["-C", &self.dir_arg(), "rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(if out.success() { Some(out.stdout_trimmed()) } else { None })
    }

    /// Read `file` from the tree at `branch`'s tip. `None` when the branch or
    /// the path is absent.
    // [impl->REQ-STORE-1]
    pub fn read_at_tip(&self, branch: &str, file: &str) -> std::io::Result<Option<Vec<u8>>> {
        let out = run_git(
            &["-C", &self.dir_arg(), "show", &format!("{branch}:{file}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(if out.success() { Some(out.stdout) } else { None })
    }

    /// Stage `paths` (worktree-relative; whole-tree when empty) in `worktree`
    /// and commit them as **one commit** — the atomic multi-key write
    /// (ADR-0011 rule). Returns the new commit id, or `None` when nothing
    /// changed (a no-op write must not mint an empty checkpoint).
    // [impl->REQ-STORE-1]
    pub fn commit_in_worktree(
        &self,
        worktree: &Path,
        paths: &[&str],
        message: &str,
    ) -> std::io::Result<Option<String>> {
        let mut add: Vec<&str> = vec!["add", "--"];
        if paths.is_empty() {
            add.push(".");
        } else {
            add.extend_from_slice(paths);
        }
        run_git_ok(&add, Some(worktree), None)?;
        let staged = run_git(
            &["diff", "--cached", "--quiet"],
            Some(worktree),
            None,
            GIT_TIMEOUT,
        )?;
        if staged.success() {
            return Ok(None); // nothing staged → no checkpoint
        }
        run_git_ok(&["commit", "-m", message], Some(worktree), None)?;
        let id = run_git_ok(&["rev-parse", "HEAD"], Some(worktree), None)?.stdout_trimmed();
        Ok(Some(id))
    }
}

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

    fn store(dir: &Path) -> BranchStore {
        BranchStore::open_or_init(&dir.join("seed.git")).expect("init bare store")
    }

    #[test] // [unit->REQ-STORE-1] init is idempotent; a fresh store has no branches.
    fn open_or_init_idempotent() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        assert!(!s.has_branch("a-doyle").unwrap());
        // Second open over the same dir: no error, same store.
        let s2 = BranchStore::open_or_init(&tmp.path().join("seed.git")).unwrap();
        assert!(!s2.has_branch("a-doyle").unwrap());
    }

    #[test] // [unit->REQ-STORE-1] write = one commit; read = tree lookup at tip; tip = resume.
    fn commit_then_read_at_tip() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        assert!(s.tip("a-doyle").unwrap().is_some(), "seed commit exists");

        std::fs::write(wt.join("live-context.md"), "mind v1").unwrap();
        let c1 = s
            .commit_in_worktree(&wt, &["live-context.md"], "write v1")
            .unwrap()
            .expect("first write commits");
        assert_eq!(s.tip("a-doyle").unwrap().as_deref(), Some(c1.as_str()));
        assert_eq!(
            s.read_at_tip("a-doyle", "live-context.md").unwrap().as_deref(),
            Some(b"mind v1".as_slice())
        );
    }

    #[test] // [unit->REQ-STORE-1] a no-op write mints no empty checkpoint.
    fn unchanged_write_is_no_commit() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        std::fs::write(wt.join("f.md"), "same").unwrap();
        let c1 = s.commit_in_worktree(&wt, &["f.md"], "w1").unwrap();
        assert!(c1.is_some());
        // Identical content again → no new commit, tip unchanged.
        std::fs::write(wt.join("f.md"), "same").unwrap();
        let c2 = s.commit_in_worktree(&wt, &["f.md"], "w2").unwrap();
        assert!(c2.is_none());
        assert_eq!(s.tip("a-doyle").unwrap(), c1);
    }

    #[test] // [unit->REQ-STORE-1] atomic multi-key: two files land in ONE commit.
    fn multi_key_commit_is_one_commit() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        let before = s.tip("a-doyle").unwrap().unwrap();
        std::fs::write(wt.join("one.md"), "1").unwrap();
        std::fs::write(wt.join("two.md"), "2").unwrap();
        s.commit_in_worktree(&wt, &["one.md", "two.md"], "both").unwrap().unwrap();
        // Exactly one commit ahead of the seed.
        let count = run_git_ok(
            &["rev-list", "--count", &format!("{before}..HEAD")],
            Some(&wt),
            None,
        )
        .unwrap()
        .stdout_trimmed();
        assert_eq!(count, "1");
    }

    #[test] // [unit->REQ-STORE-1] branches are independent KV roots (parentless seeds).
    fn branches_are_independent_roots() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        s.ensure_branch("a-doyle").unwrap();
        s.ensure_branch("p-proj").unwrap();
        let t1 = s.tip("a-doyle").unwrap().unwrap();
        let t2 = s.tip("p-proj").unwrap().unwrap();
        assert_ne!(t1, t2, "distinct seed commits");
        assert!(s.read_at_tip("a-doyle", "anything.md").unwrap().is_none());
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs (file state is current in your context — no need to Read it back)

//! `BranchStore` — a git branch used as a versioned key/value filesystem
//! (ADR-0011, REQ-STORE-1). The substrate for **coarse / durable / audited**
//! state: write = one commit, read = tree lookup at the branch tip, history =
//! `git log`, recovery = read the ref tip.
//!
//! **commit = checkpoint, tip = resume.** Git's ref update is atomic, so a
//! crash mid-write never advances the branch — recovery is "read the tip",
//! which dissolves the torn-write hazard class (5.2) for state stored here.
//!
//! **Single-writer-per-ref** (ADR-0011 rule): refs are sharded per
//! endpoint/project (`a-<id>` / `p-<project>` for context; `spt/...` for
//! machinery state), matching the existing single-writer invariants (6.4,
//! active-instance-authoritative). No many-writers-one-ref contention is
//! modeled, exactly like [`crate::epoch::EpochSource`].
//!
//! v1 drives the **system `git` binary** through the bounded runner
//! ([`crate::gitrun`], hazard 5.3) — ADR-0013 confirmed git-CLI over gitoxide;
//! the API is shaped so an in-process backend is a later drop-in. The seed
//! repo is **bare**; consumers read/write through **linked worktrees** (one
//! per branch), kept relocatable via `worktree.useRelativePaths` where git is
//! new enough, with [`BranchStore::repair_worktrees`] as the older-git
//! fallback (STORAGE §Worktree portability).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::gitrun::{run_git, run_git_ok, GIT_TIMEOUT};

/// A bare git repository hosting branch-as-KV stores.
pub struct BranchStore {
    git_dir: PathBuf,
}

impl BranchStore {
    /// Open the bare repo at `git_dir`, initializing it if absent. Idempotent.
    /// Init pins byte-exact storage (`core.autocrlf=false` — context files are
    /// merge-driven by their marker bytes, CRLF translation would corrupt the
    /// vector math) and opts into relative worktree links where supported
    /// (unknown config keys are harmless on older git).
    // [impl->REQ-STORE-1]
    pub fn open_or_init(git_dir: &Path) -> std::io::Result<BranchStore> {
        let head = git_dir.join("HEAD");
        if !head.exists() {
            std::fs::create_dir_all(git_dir)?;
            run_git_ok(
                &["init", "--bare", &git_dir.to_string_lossy()],
                None,
                None,
            )?;
            let dir = git_dir.to_string_lossy().to_string();
            run_git_ok(&["-C", &dir, "config", "core.autocrlf", "false"], None, None)?;
            // Git ≥ 2.48; best-effort on older git (repair_worktrees covers it).
            let _ = run_git(
                &["-C", &dir, "config", "worktree.useRelativePaths", "true"],
                None,
                None,
                GIT_TIMEOUT,
            );
        }
        Ok(BranchStore { git_dir: git_dir.to_path_buf() })
    }

    fn dir_arg(&self) -> String {
        self.git_dir.to_string_lossy().to_string()
    }

    /// Whether `branch` exists in this store.
    pub fn has_branch(&self, branch: &str) -> std::io::Result<bool> {
        let out = run_git(
            &["-C", &self.dir_arg(), "show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(out.success())
    }

    /// Ensure `branch` exists, creating it as an **empty-tree root commit**
    /// when absent (no parent — each branch is an independent KV root; sync
    /// joins histories with `--allow-unrelated-histories` at first contact,
    /// STORAGE §Cross-node context sync). Idempotent.
    // [impl->REQ-STORE-1]
    pub fn ensure_branch(&self, branch: &str) -> std::io::Result<()> {
        if self.has_branch(branch)? {
            return Ok(());
        }
        let dir = self.dir_arg();
        // Empty stdin → the empty tree; commit-tree → a parentless seed commit.
        let tree = run_git_ok(&["-C", &dir, "mktree"], None, Some(b""))?.stdout_trimmed();
        let seed = run_git_ok(
            &["-C", &dir, "commit-tree", &tree, "-m", &format!("seed {branch}")],
            None,
            None,
        )?
        .stdout_trimmed();
        run_git_ok(&["-C", &dir, "branch", branch, &seed], None, None)?;
        Ok(())
    }

    /// Ensure a linked worktree for `branch` exists at `path` (creating the
    /// branch first if needed). Idempotent: an existing worktree is left
    /// untouched.
    // [impl->REQ-STORE-1]
    pub fn ensure_worktree(&self, branch: &str, path: &Path) -> std::io::Result<()> {
        if path.join(".git").exists() {
            return Ok(());
        }
        self.ensure_branch(branch)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        run_git_ok(
            &["-C", &self.dir_arg(), "worktree", "add", &path.to_string_lossy(), branch],
            None,
            None,
        )?;
        Ok(())
    }

    /// Re-point worktree links after a `$SPT_HOME` relocation (the fallback
    /// for git too old for relative worktree links — STORAGE §Worktree
    /// portability). Safe to run any time.
    pub fn repair_worktrees(&self) -> std::io::Result<()> {
        run_git_ok(&["-C", &self.dir_arg(), "worktree", "repair"], None, None)?;
        Ok(())
    }

    /// The branch tip commit id, `None` for an absent branch — **tip = resume**
    /// (the recovery read; a crash mid-write never advanced this).
    // [impl->REQ-STORE-1]
    pub fn tip(&self, branch: &str) -> std::io::Result<Option<String>> {
        let out = run_git(
            &["-C", &self.dir_arg(), "rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(if out.success() { Some(out.stdout_trimmed()) } else { None })
    }

    /// Read `file` from the tree at `branch`'s tip. `None` when the branch or
    /// the path is absent.
    // [impl->REQ-STORE-1]
    pub fn read_at_tip(&self, branch: &str, file: &str) -> std::io::Result<Option<Vec<u8>>> {
        let out = run_git(
            &["-C", &self.dir_arg(), "show", &format!("{branch}:{file}")],
            None,
            None,
            GIT_TIMEOUT,
        )?;
        Ok(if out.success() { Some(out.stdout) } else { None })
    }

    /// Stage `paths` (worktree-relative; whole-tree when empty) in `worktree`
    /// and commit them as **one commit** — the atomic multi-key write
    /// (ADR-0011 rule). Returns the new commit id, or `None` when nothing
    /// changed (a no-op write must not mint an empty checkpoint).
    // [impl->REQ-STORE-1]
    pub fn commit_in_worktree(
        &self,
        worktree: &Path,
        paths: &[&str],
        message: &str,
    ) -> std::io::Result<Option<String>> {
        let mut add: Vec<&str> = vec!["add", "--"];
        if paths.is_empty() {
            add.push(".");
        } else {
            add.extend_from_slice(paths);
        }
        run_git_ok(&add, Some(worktree), None)?;
        let staged = run_git(
            &["diff", "--cached", "--quiet"],
            Some(worktree),
            None,
            GIT_TIMEOUT,
        )?;
        if staged.success() {
            return Ok(None); // nothing staged → no checkpoint
        }
        run_git_ok(&["commit", "-m", message], Some(worktree), None)?;
        let id = run_git_ok(&["rev-parse", "HEAD"], Some(worktree), None)?.stdout_trimmed();
        Ok(Some(id))
    }
}

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

    fn store(dir: &Path) -> BranchStore {
        BranchStore::open_or_init(&dir.join("seed.git")).expect("init bare store")
    }

    #[test] // [unit->REQ-STORE-1] init is idempotent; a fresh store has no branches.
    fn open_or_init_idempotent() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        assert!(!s.has_branch("a-doyle").unwrap());
        // Second open over the same dir: no error, same store.
        let s2 = BranchStore::open_or_init(&tmp.path().join("seed.git")).unwrap();
        assert!(!s2.has_branch("a-doyle").unwrap());
    }

    #[test] // [unit->REQ-STORE-1] write = one commit; read = tree lookup at tip; tip = resume.
    fn commit_then_read_at_tip() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        assert!(s.tip("a-doyle").unwrap().is_some(), "seed commit exists");

        std::fs::write(wt.join("live-context.md"), "mind v1").unwrap();
        let c1 = s
            .commit_in_worktree(&wt, &["live-context.md"], "write v1")
            .unwrap()
            .expect("first write commits");
        assert_eq!(s.tip("a-doyle").unwrap().as_deref(), Some(c1.as_str()));
        assert_eq!(
            s.read_at_tip("a-doyle", "live-context.md").unwrap().as_deref(),
            Some(b"mind v1".as_slice())
        );
    }

    #[test] // [unit->REQ-STORE-1] a no-op write mints no empty checkpoint.
    fn unchanged_write_is_no_commit() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        std::fs::write(wt.join("f.md"), "same").unwrap();
        let c1 = s.commit_in_worktree(&wt, &["f.md"], "w1").unwrap();
        assert!(c1.is_some());
        // Identical content again → no new commit, tip unchanged.
        std::fs::write(wt.join("f.md"), "same").unwrap();
        let c2 = s.commit_in_worktree(&wt, &["f.md"], "w2").unwrap();
        assert!(c2.is_none());
        assert_eq!(s.tip("a-doyle").unwrap(), c1);
    }

    #[test] // [unit->REQ-STORE-1] atomic multi-key: two files land in ONE commit.
    fn multi_key_commit_is_one_commit() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        let wt = tmp.path().join("wt");
        s.ensure_worktree("a-doyle", &wt).unwrap();
        let before = s.tip("a-doyle").unwrap().unwrap();
        std::fs::write(wt.join("one.md"), "1").unwrap();
        std::fs::write(wt.join("two.md"), "2").unwrap();
        s.commit_in_worktree(&wt, &["one.md", "two.md"], "both").unwrap().unwrap();
        // Exactly one commit ahead of the seed.
        let count = run_git_ok(
            &["rev-list", "--count", &format!("{before}..HEAD")],
            Some(&wt),
            None,
        )
        .unwrap()
        .stdout_trimmed();
        assert_eq!(count, "1");
    }

    #[test] // [unit->REQ-STORE-1] branches are independent KV roots (parentless seeds).
    fn branches_are_independent_roots() {
        let tmp = tempdir().unwrap();
        let s = store(tmp.path());
        s.ensure_branch("a-doyle").unwrap();
        s.ensure_branch("p-proj").unwrap();
        let t1 = s.tip("a-doyle").unwrap().unwrap();
        let t2 = s.tip("p-proj").unwrap().unwrap();
        assert_ne!(t1, t2, "distinct seed commits");
        assert!(s.read_at_tip("a-doyle", "anything.md").unwrap().is_none());
    }
}


crates\spt-proto\src\addr.rs:9://! ([`crate::id`], REQ-HAZARD-ID-CHARSET), so parsing is unambiguous: there is at most
crates\spt-proto\src\id.rs:17:// [impl->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:94:    // [unit->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:111:    // [unit->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:124:    // [unit->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:134:    // [unit->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:142:    // [unit->REQ-HAZARD-ID-CHARSET]
crates\spt-proto\src\id.rs:154:    // [unit->REQ-HAZARD-ID-CHARSET]


1	//! Endpoint-id charset contract — the addressable-name validator.
2	//!
3	//! Every addressable endpoint id/name is restricted to a deliberately narrow
4	//! charset so that:
5	//!   - `:` and `@` stay **reserved** as the future address delimiters of the
6	//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
7	//!     no bare id can ever contain them, so qualified-address parsing is
8	//!     unambiguous and no permissive id-data accumulates that would need a
9	//!     later migration;
10	//!   - path separators, whitespace, and control characters cannot smuggle into
11	//!     a perch directory name or a wire target string.
12	//!
13	//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
14	//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
15	//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
16	//! only `-` + alphanumerics, so composite ids validate cleanly.
17	// [impl->REQ-HAZARD-ID-CHARSET]
18
19	/// Maximum endpoint-id length, in Unicode scalar values.
20	pub const MAX_ENDPOINT_ID_LEN: usize = 64;
21
22	/// Why an endpoint id was rejected.
23	#[derive(Debug, Clone, PartialEq, Eq)]
24	pub enum IdError {
25	    /// The id was the empty string.
26	    Empty,
27	    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
28	    TooLong { len: usize, max: usize },
29	    /// A disallowed character was found (with its scalar position).
30	    BadChar { ch: char, pos: usize },
31	}
32
33	impl std::fmt::Display for IdError {
34	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35	        match self {
36	            IdError::Empty => write!(f, "endpoint id must not be empty"),
37	            IdError::TooLong { len, max } => {
38	                write!(f, "endpoint id too long ({len} chars; max {max})")
39	            }
40	            IdError::BadChar { ch, pos } => write!(
41	                f,
42	                "endpoint id has a disallowed character {ch:?} at position {pos} \
43	                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
44	                 `:` and `@` are reserved address delimiters)"
45	            ),
46	        }
47	    }
48	}
49
50	impl std::error::Error for IdError {}
51
52	/// True if `c` is permitted in an endpoint id.
53	pub fn is_allowed_id_char(c: char) -> bool {
54	    c.is_ascii_alphanumeric()
55	        || c == '-'
56	        || c == '_'
57	        || matches!(c,
58	            '\u{3040}'..='\u{309F}'   // Hiragana
59	            | '\u{30A0}'..='\u{30FF}' // Katakana
60	            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
61	        )
62	}
63
64	/// Validate an addressable endpoint id/name against the charset contract.
65	/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
66	pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
67	    if id.is_empty() {
68	        return Err(IdError::Empty);
69	    }
70	    let len = id.chars().count();
71	    if len > MAX_ENDPOINT_ID_LEN {
72	        return Err(IdError::TooLong {
73	            len,
74	            max: MAX_ENDPOINT_ID_LEN,
75	        });
76	    }
77	    for (pos, ch) in id.chars().enumerate() {
78	        if !is_allowed_id_char(ch) {
79	            return Err(IdError::BadChar { ch, pos });
80	        }
81	    }
82	    Ok(())
83	}
84
85	/// Convenience boolean form of [`validate_endpoint_id`].
86	pub fn is_valid_endpoint_id(id: &str) -> bool {
87	    validate_endpoint_id(id).is_ok()
88	}
89
90	#[cfg(test)]
91	mod tests {
92	    use super::*;
93
94	    // [unit->REQ-HAZARD-ID-CHARSET]
95	    #[test]

//! Endpoint-id charset contract — the addressable-name validator.
//!
//! Every addressable endpoint id/name is restricted to a deliberately narrow
//! charset so that:
//!   - `:` and `@` stay **reserved** as the future address delimiters of the
//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
//!     no bare id can ever contain them, so qualified-address parsing is
//!     unambiguous and no permissive id-data accumulates that would need a
//!     later migration;
//!   - path separators, whitespace, and control characters cannot smuggle into
//!     a perch directory name or a wire target string.
//!
//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
//! only `-` + alphanumerics, so composite ids validate cleanly.
// [impl->REQ-HAZARD-ID-CHARSET]

/// Maximum endpoint-id length, in Unicode scalar values.
pub const MAX_ENDPOINT_ID_LEN: usize = 64;

/// Why an endpoint id was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdError {
    /// The id was the empty string.
    Empty,
    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
    TooLong { len: usize, max: usize },
    /// A disallowed character was found (with its scalar position).
    BadChar { ch: char, pos: usize },
}

impl std::fmt::Display for IdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IdError::Empty => write!(f, "endpoint id must not be empty"),
            IdError::TooLong { len, max } => {
                write!(f, "endpoint id too long ({len} chars; max {max})")
            }
            IdError::BadChar { ch, pos } => write!(
                f,
                "endpoint id has a disallowed character {ch:?} at position {pos} \
                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
                 `:` and `@` are reserved address delimiters)"
            ),
        }
    }
}

impl std::error::Error for IdError {}

/// True if `c` is permitted in an endpoint id.
pub fn is_allowed_id_char(c: char) -> bool {
    c.is_ascii_alphanumeric()
        || c == '-'
        || c == '_'
        || matches!(c,
            '\u{3040}'..='\u{309F}'   // Hiragana
            | '\u{30A0}'..='\u{30FF}' // Katakana
            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
        )
}

/// Validate an addressable endpoint id/name against the charset contract.
/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
    if id.is_empty() {
        return Err(IdError::Empty);
    }
    let len = id.chars().count();
    if len > MAX_ENDPOINT_ID_LEN {
        return Err(IdError::TooLong {
            len,
            max: MAX_ENDPOINT_ID_LEN,
        });
    }
    for (pos, ch) in id.chars().enumerate() {
        if !is_allowed_id_char(ch) {
            return Err(IdError::BadChar { ch, pos });
        }
    }
    Ok(())
}

/// Convenience boolean form of [`validate_endpoint_id`].
pub fn is_valid_endpoint_id(id: &str) -> bool {
    validate_endpoint_id(id).is_ok()
}

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

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]


ID validator exists, charset is git-safe. Now ContextStore + project_id:


//! The two-tier context store — `tracked/` realized over [`BranchStore`]
//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
//!
//! Layout (the synced *mind*, physically separate from node-local `perches/`):
//!
//! ```text
//! tracked/
//!   .seed.git                    bare BranchStore repo (the one true object DB)
//!   agents/<id>/                 linked worktree of branch  a-<id>
//!     live-context.md            per-agent live context  → syncs to ALL instances
//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
//!     <id>/project-context.md    per-(agent,project)      → same-project instances
//! ```
//!
//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
//! exchange ships); worktrees are the read/write view. Tier routing is the
//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
//! everywhere, the `p-` branch holds every agent's context for one project
//! (cross-agent synthesis + query-routing ride that shape).
//!
//! Writes here are **paths + commits only** — the precedence-marker guard
//! (6.5) stays in `spt-live::context`, which writes through the paths this
//! store hands out, then calls the commit hook. Single writer per branch
//! (active-instance-authoritative; 6.4).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::branchstore::BranchStore;
use crate::perch::tracked_dir;
use spt_proto::id::validate_endpoint_id;

/// The per-agent live-context file inside an `a-<id>` worktree.
pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
        Ok(ContextStore { store, root: root.to_path_buf() })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.ensure_worktree(&agent_branch(id), &wt)?;
        Ok(wt.join(LIVE_CONTEXT_FILE))
    }

    /// Ensure the `p-<project_id>` branch + worktree exist; return the
    /// per-(agent,project) context file path inside it
    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
    /// components are charset-validated (4.6; project ids come from
    /// [`crate::project::project_id_for_dir`], which emits the same charset).
    // [impl->REQ-STORE-1]
    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        let dir = wt.join(id);
        std::fs::create_dir_all(&dir)?;
        Ok(dir.join(PROJECT_CONTEXT_FILE))
    }

    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
    /// (no-op when nothing changed). Call after a guarded write through
    /// [`live_context_path`](Self::live_context_path).
    // [impl->REQ-STORE-1]
    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
    /// when nothing changed).
    // [impl->REQ-STORE-1]
    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(project_id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.commit_in_worktree(&wt, &[], message)
    }
}

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

    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
    fn two_tier_layout_and_commits() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();

        let live = cs.live_context_path("doyle").unwrap();
        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
        std::fs::write(&live, "live mind").unwrap();
        let c = cs.commit_live("doyle", "live v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
            Some(b"live mind".to_vec())
        );

        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        assert!(proj.ends_with(
            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
        ));
        std::fs::write(&proj, "project mind").unwrap();
        let c = cs.commit_project("spt-core", "proj v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
                .unwrap(),
            Some(b"project mind".to_vec())
        );
    }

    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
    fn project_branch_holds_many_agents() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for id in ["doyle", "momo"] {
            let p = cs.project_context_path("spt-core", id).unwrap();
            std::fs::write(&p, format!("{id} ctx")).unwrap();
        }
        cs.commit_project("spt-core", "both").unwrap().unwrap();
        let bs = cs.branch_store();
        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
    }

    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
    fn invalid_id_refused() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
        }
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs (file state is current in your context — no need to Read it back)

//! The two-tier context store — `tracked/` realized over [`BranchStore`]
//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
//!
//! Layout (the synced *mind*, physically separate from node-local `perches/`):
//!
//! ```text
//! tracked/
//!   .seed.git                    bare BranchStore repo (the one true object DB)
//!   agents/<id>/                 linked worktree of branch  a-<id>
//!     live-context.md            per-agent live context  → syncs to ALL instances
//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
//!     <id>/project-context.md    per-(agent,project)      → same-project instances
//! ```
//!
//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
//! exchange ships); worktrees are the read/write view. Tier routing is the
//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
//! everywhere, the `p-` branch holds every agent's context for one project
//! (cross-agent synthesis + query-routing ride that shape).
//!
//! Writes here are **paths + commits only** — the precedence-marker guard
//! (6.5) stays in `spt-live::context`, which writes through the paths this
//! store hands out, then calls the commit hook. Single writer per branch
//! (active-instance-authoritative; 6.4).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::branchstore::BranchStore;
use crate::perch::tracked_dir;
use spt_proto::id::validate_endpoint_id;

/// The per-agent live-context file inside an `a-<id>` worktree.
pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
        Ok(ContextStore { store, root: root.to_path_buf() })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.ensure_worktree(&agent_branch(id), &wt)?;
        Ok(wt.join(LIVE_CONTEXT_FILE))
    }

    /// Ensure the `p-<project_id>` branch + worktree exist; return the
    /// per-(agent,project) context file path inside it
    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
    /// components are charset-validated (4.6; project ids come from
    /// [`crate::project::project_id_for_dir`], which emits the same charset).
    // [impl->REQ-STORE-1]
    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        let dir = wt.join(id);
        std::fs::create_dir_all(&dir)?;
        Ok(dir.join(PROJECT_CONTEXT_FILE))
    }

    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
    /// (no-op when nothing changed). Call after a guarded write through
    /// [`live_context_path`](Self::live_context_path).
    // [impl->REQ-STORE-1]
    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
    /// when nothing changed).
    // [impl->REQ-STORE-1]
    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(project_id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.commit_in_worktree(&wt, &[], message)
    }
}

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

    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
    fn two_tier_layout_and_commits() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();

        let live = cs.live_context_path("doyle").unwrap();
        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
        std::fs::write(&live, "live mind").unwrap();
        let c = cs.commit_live("doyle", "live v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
            Some(b"live mind".to_vec())
        );

        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        assert!(proj.ends_with(
            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
        ));
        std::fs::write(&proj, "project mind").unwrap();
        let c = cs.commit_project("spt-core", "proj v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
                .unwrap(),
            Some(b"project mind".to_vec())
        );
    }

    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
    fn project_branch_holds_many_agents() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for id in ["doyle", "momo"] {
            let p = cs.project_context_path("spt-core", id).unwrap();
            std::fs::write(&p, format!("{id} ctx")).unwrap();
        }
        cs.commit_project("spt-core", "both").unwrap().unwrap();
        let bs = cs.branch_store();
        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
    }

    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
    fn invalid_id_refused() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
        }
    }
}


//! `project_id` derivation (STORAGE §project_id) — the key that makes
//! project-context sync match across machines.
//!
//! Derived **from the project's git remote URL** when it has one: stable and
//! path-independent, so the same project at `/home/x/proj` and
//! `C:\Users\x\proj` resolves to the same id (which is what lets D6's
//! `p-<project_id>` branches line up across nodes). A git repo without a
//! remote falls back to its toplevel **folder name**; a non-git dir falls back
//! to its own folder name (documented limitation: folder names are not
//! guaranteed path-independent across machines).
//!
//! The emitted id is a slug in the endpoint-id charset (4.6) — lowercase
//! `[a-z0-9-]` — so it is valid as a branch-name component and a directory
//! name everywhere, and two URL spellings of one remote (SSH vs HTTPS,
//! trailing `.git`, case) normalize to one id.
// [impl->REQ-STORE-1]

use std::path::Path;
use std::time::Duration;

use crate::gitrun::run_git;

/// Budget for the (local, fast) `git remote get-url` / `rev-parse` reads.
const PROJECT_GIT_TIMEOUT: Duration = Duration::from_secs(10);

/// Slug a remote URL (or any name) into the project-id charset: lowercase,
/// scheme/credentials/`.git` stripped, every non-`[a-z0-9]` run collapsed to
/// one `-`, trimmed of leading/trailing `-`.
fn slug(raw: &str) -> String {
    let mut s = raw.trim().to_ascii_lowercase();
    // Strip URL scheme and credentials: `https://user@host/p` → `host/p`,
    // `git@host:p` → `host:p` (the `:` collapses to `-` below).
    if let Some(idx) = s.find("://") {
        s = s[idx + 3..].to_string();
    }
    if let Some(idx) = s.find('@') {
        s = s[idx + 1..].to_string();
    }
    if let Some(stripped) = s.strip_suffix(".git") {
        s = stripped.to_string();
    }
    let mut out = String::with_capacity(s.len());
    let mut last_dash = true; // suppress a leading '-'
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c);
            last_dash = false;
        } else if !last_dash {
            out.push('-');
            last_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    out
}

/// Derive the `project_id` for a directory inside (or being) a project.
/// Remote-URL first, then git-toplevel folder name, then the dir's own name;
/// an empty derivation degrades to `"unnamed-project"` rather than erroring —
/// a broken repo must not stall context routing.
// [impl->REQ-STORE-1]
pub fn project_id_for_dir(dir: &Path) -> String {
    let dir_arg = dir.to_string_lossy().to_string();

    // 1. Remote URL (stable + path-independent).
    if let Ok(out) = run_git(
        &["-C", &dir_arg, "remote", "get-url", "origin"],
        None,
        None,
        PROJECT_GIT_TIMEOUT,
    ) {
        if out.success() {
            let id = slug(&out.stdout_trimmed());
            if !id.is_empty() {
                return id;
            }
        }
    }

    // 2. Git toplevel folder name (repo without a remote).
    if let Ok(out) = run_git(
        &["-C", &dir_arg, "rev-parse", "--show-toplevel"],
        None,
        None,
        PROJECT_GIT_TIMEOUT,
    ) {
        if out.success() {
            if let Some(name) = Path::new(&out.stdout_trimmed())
                .file_name()
                .map(|n| n.to_string_lossy().to_string())
            {
                let id = slug(&name);
                if !id.is_empty() {
                    return id;
                }
            }
        }
    }

    // 3. The directory's own folder name.
    let id = dir
        .file_name()
        .map(|n| slug(&n.to_string_lossy()))
        .unwrap_or_default();
    if id.is_empty() { "unnamed-project".to_string() } else { id }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gitrun::run_git_ok;
    use tempfile::tempdir;

    #[test] // [unit->REQ-STORE-1] SSH and HTTPS spellings of one remote slug to ONE id.
    fn url_spellings_normalize_to_one_id() {
        let a = slug("git@github.com:SaberMage/spt-core.git");
        let b = slug("https://github.com/sabermage/spt-core");
        assert_eq!(a, "github-com-sabermage-spt-core");
        assert_eq!(a, b);
    }

    #[test] // [unit->REQ-STORE-1] remote beats folder name; no remote falls to toplevel folder; no git falls to dir name.
    fn derivation_ladder() {
        // Non-git dir → its own folder name.
        let plain = tempdir().unwrap();
        let sub = plain.path().join("My Project");
        std::fs::create_dir_all(&sub).unwrap();
        assert_eq!(project_id_for_dir(&sub), "my-project");

        // Git repo without a remote → toplevel folder name (queried from a subdir).
        let repo = tempdir().unwrap();
        let root = repo.path().join("proj-x");
        std::fs::create_dir_all(root.join("nested")).unwrap();
        run_git_ok(&["init", &root.to_string_lossy()], None, None).unwrap();
        assert_eq!(project_id_for_dir(&root.join("nested")), "proj-x");

        // With a remote → the normalized URL wins.
        run_git_ok(
            &["-C", &root.to_string_lossy(), "remote", "add", "origin", "git@example.com:Team/Repo.git"],
            None,
            None,
        )
        .unwrap();
        assert_eq!(project_id_for_dir(&root.join("nested")), "example-com-team-repo");
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\project.rs (file state is current in your context — no need to Read it back)

//! `project_id` derivation (STORAGE §project_id) — the key that makes
//! project-context sync match across machines.
//!
//! Derived **from the project's git remote URL** when it has one: stable and
//! path-independent, so the same project at `/home/x/proj` and
//! `C:\Users\x\proj` resolves to the same id (which is what lets D6's
//! `p-<project_id>` branches line up across nodes). A git repo without a
//! remote falls back to its toplevel **folder name**; a non-git dir falls back
//! to its own folder name (documented limitation: folder names are not
//! guaranteed path-independent across machines).
//!
//! The emitted id is a slug in the endpoint-id charset (4.6) — lowercase
//! `[a-z0-9-]` — so it is valid as a branch-name component and a directory
//! name everywhere, and two URL spellings of one remote (SSH vs HTTPS,
//! trailing `.git`, case) normalize to one id.
// [impl->REQ-STORE-1]

use std::path::Path;
use std::time::Duration;

use crate::gitrun::run_git;

/// Budget for the (local, fast) `git remote get-url` / `rev-parse` reads.
const PROJECT_GIT_TIMEOUT: Duration = Duration::from_secs(10);

/// Slug a remote URL (or any name) into the project-id charset: lowercase,
/// scheme/credentials/`.git` stripped, every non-`[a-z0-9]` run collapsed to
/// one `-`, trimmed of leading/trailing `-`.
fn slug(raw: &str) -> String {
    let mut s = raw.trim().to_ascii_lowercase();
    // Strip URL scheme and credentials: `https://user@host/p` → `host/p`,
    // `git@host:p` → `host:p` (the `:` collapses to `-` below).
    if let Some(idx) = s.find("://") {
        s = s[idx + 3..].to_string();
    }
    if let Some(idx) = s.find('@') {
        s = s[idx + 1..].to_string();
    }
    if let Some(stripped) = s.strip_suffix(".git") {
        s = stripped.to_string();
    }
    let mut out = String::with_capacity(s.len());
    let mut last_dash = true; // suppress a leading '-'
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c);
            last_dash = false;
        } else if !last_dash {
            out.push('-');
            last_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    out
}

/// Derive the `project_id` for a directory inside (or being) a project.
/// Remote-URL first, then git-toplevel folder name, then the dir's own name;
/// an empty derivation degrades to `"unnamed-project"` rather than erroring —
/// a broken repo must not stall context routing.
// [impl->REQ-STORE-1]
pub fn project_id_for_dir(dir: &Path) -> String {
    let dir_arg = dir.to_string_lossy().to_string();

    // 1. Remote URL (stable + path-independent).
    if let Ok(out) = run_git(
        &["-C", &dir_arg, "remote", "get-url", "origin"],
        None,
        None,
        PROJECT_GIT_TIMEOUT,
    ) {
        if out.success() {
            let id = slug(&out.stdout_trimmed());
            if !id.is_empty() {
                return id;
            }
        }
    }

    // 2. Git toplevel folder name (repo without a remote).
    if let Ok(out) = run_git(
        &["-C", &dir_arg, "rev-parse", "--show-toplevel"],
        None,
        None,
        PROJECT_GIT_TIMEOUT,
    ) {
        if out.success() {
            if let Some(name) = Path::new(&out.stdout_trimmed())
                .file_name()
                .map(|n| n.to_string_lossy().to_string())
            {
                let id = slug(&name);
                if !id.is_empty() {
                    return id;
                }
            }
        }
    }

    // 3. The directory's own folder name.
    let id = dir
        .file_name()
        .map(|n| slug(&n.to_string_lossy()))
        .unwrap_or_default();
    if id.is_empty() { "unnamed-project".to_string() } else { id }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gitrun::run_git_ok;
    use tempfile::tempdir;

    #[test] // [unit->REQ-STORE-1] SSH and HTTPS spellings of one remote slug to ONE id.
    fn url_spellings_normalize_to_one_id() {
        let a = slug("git@github.com:SaberMage/spt-core.git");
        let b = slug("https://github.com/sabermage/spt-core");
        assert_eq!(a, "github-com-sabermage-spt-core");
        assert_eq!(a, b);
    }

    #[test] // [unit->REQ-STORE-1] remote beats folder name; no remote falls to toplevel folder; no git falls to dir name.
    fn derivation_ladder() {
        // Non-git dir → its own folder name.
        let plain = tempdir().unwrap();
        let sub = plain.path().join("My Project");
        std::fs::create_dir_all(&sub).unwrap();
        assert_eq!(project_id_for_dir(&sub), "my-project");

        // Git repo without a remote → toplevel folder name (queried from a subdir).
        let repo = tempdir().unwrap();
        let root = repo.path().join("proj-x");
        std::fs::create_dir_all(root.join("nested")).unwrap();
        run_git_ok(&["init", &root.to_string_lossy()], None, None).unwrap();
        assert_eq!(project_id_for_dir(&root.join("nested")), "proj-x");

        // With a remote → the normalized URL wins.
        run_git_ok(
            &["-C", &root.to_string_lossy(), "remote", "add", "origin", "git@example.com:Team/Repo.git"],
            None,
            None,
        )
        .unwrap();
        assert_eq!(project_id_for_dir(&root.join("nested")), "example-com-team-repo");
    }
}


60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
67	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
68	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
69	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
70	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
71	pub fn identity_dir() -> PathBuf {
72	    spt_home().join("identity")
73	}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}


D6a-1: BranchStore + ContextStore + project_id in spt-store

gitrun.rs (bounded git, 5.3), branchstore.rs (git-CLI BranchStore: bare seed, ensure branch/worktree, commit=checkpoint/tip=resume, REQ-STORE-1 activate), contextstore.rs (tracked/ two-tier layout, a-/p- branches), project.rs (project_id from git remote / folder fallback), perch.rs tracked paths, STORAGE.md seed-repo note. Push CI-green.

D6a-2: two-tier ingest routing + rename arm in spt-live

ingest.rs routes commune slices (parse_two_slice) into ContextStore tier files (precedence-guarded + committed); lifecycle wiring (project dir → project_id); spt rename context-branch arm (a-<old>→a-<new>); REQ-INST-2 activate [impl,unit]. Push CI-green.

D6b: vector merge + Psyche reconcile + D7.5a driver

VersionVector + marker v2 (node+vector), per-file merge fn, conflict artifacts (REQ-HAZARD-CONFLICT-BOTH-PRESERVED register+activate), reconciler election, bounded stdout-captured Psyche turn driver (D7.5a pulled forward), reconcile flow + validation + retry + event seam.

D6c: bundle sync over broker streams + gates

SyncRequest/bundle-chunk records, pull-based ref-scoped (synced(E,S)+visibility server-side fail-closed), fetch+driver merge+two-parent commits, triggers (write-notify, activation freshness feed, anti-entropy), loopback two-daemon tests, FAULT-MATRIX rows. REQ-NET-3 + REQ-INST-5 activate.


Edit

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
/// across nodes** (the backup/migrate set), physically separate from the
/// never-synced `perches/` runtime. Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn tracked_dir() -> PathBuf {
    spt_home().join("tracked")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
/// across nodes** (the backup/migrate set), physically separate from the
/// never-synced `perches/` runtime. Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn tracked_dir() -> PathBuf {
    spt_home().join("tracked")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

+/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).

+/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced

+/// across nodes** (the backup/migrate set), physically separate from the

+/// never-synced `perches/` runtime. Pure path composition.


pub mod access;
pub mod atomic;
mod db;
pub mod epoch;

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
mod db;
pub mod epoch;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod access;
pub mod atomic;
+pub mod branchstore;
+pub mod contextstore;
mod db;
pub mod epoch;
+pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;


pub mod perch;
pub mod proc;
pub mod project;


pub mod perch;
pub mod proc;
pub mod project;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod nodeid;
pub mod perch;
pub mod proc;
+pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;


1	[package]
2	name = "spt-store"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core persistence layer: atomic writes, spool, perch layout, registry, info.json. Built on spt-proto."
7
8	[dependencies]
9	rusqlite = { version = "0.32", features = ["bundled"] }
10	serde = { version = "1", features = ["derive"] }
11	serde_json = { version = "1", features = ["preserve_order"] }
12	spt-proto = { path = "../spt-proto" }
13
14	[target.'cfg(unix)'.dependencies]
15	libc = "0.2"
16
17	[dev-dependencies]
18	tempfile = "3"
19

[package]
name = "spt-store"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core persistence layer: atomic writes, spool, perch layout, registry, info.json. Built on spt-proto."

[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
spt-proto = { path = "../spt-proto" }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tempfile = "3"


Deps in place. Build + test spt-store:


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-store --lib 2>&1 | tail -30

Build + run spt-store unit tests


test trust::tests::repin_applies_accepted_key_change ... ok
test trust::tests::revoke_removes_trust ... ok
test subnet::tests::missing_or_corrupt_file_is_empty_store ... ok
test trust::tests::trust_is_scoped_per_subnet ... ok
test trust::tests::unlabelled_distinct_keys_each_pin ... ok
test visibility::tests::hidden_gates_sync_even_when_listed ... ok
test visibility::tests::hidden_is_or_of_defaults ... ok
test visibility::tests::override_wins_both_directions ... ok
test subnet::tests::store_persists_multi_subnet_and_rotation ... ok
test visibility::tests::synced_requires_membership_listing ... ok
test visibility::tests::store_persists_and_degrades_safe ... ok
test spool::tests::drain_marks_delivered_and_is_idempotent ... ok
test trust::tests::store_persists_and_degrades_gracefully ... ok
test xfer::tests::xfer_id_is_filename_safe_and_distinct ... ok
test xfer::tests::progress_round_trips_per_role_and_updates_mid_flight ... ok
test spool::tests::deferred_row_survives_event_drain_then_hook_delivers ... ok
test spool::tests::non_deferred_drain_skips_deferred_and_preserves_order ... ok
test project::tests::derivation_ladder ... ok
test spool::tests::wan_spool_dedups_on_op_id_across_reopen ... ok
test registry::tests::concurrent_registration_never_locks ... ok
test spool::tests::event_part_body_persists_byte_exact ... ok
test branchstore::tests::branches_are_independent_roots ... ok
test branchstore::tests::multi_key_commit_is_one_commit ... ok
test branchstore::tests::unchanged_write_is_no_commit ... ok
test contextstore::tests::project_branch_holds_many_agents ... ok
test branchstore::tests::commit_then_read_at_tip ... ok
test contextstore::tests::two_tier_layout_and_commits ... ok

test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.35s


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy -p spt-store --all-targets 2>&1 | tail -8

Clippy spt-store


    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.36s


Edit

id = "REQ-STORE-1"
title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
required_stages = []

id = "REQ-STORE-1"
title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
required_stages = ["impl", "unit"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no-commit + one-commit-multi-key + independent-roots + tier layout/commits + p-branch-aggregates-agents + invalid-id-refused + url-normalization + derivation ladder


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-STORE-1"
title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
required_stages = []

id = "REQ-STORE-1"
title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
required_stages = ["impl", "unit"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no-commit + one-commit-multi-key + independent-roots + tier layout/commits + p-branch-aggregates-agents + invalid-id-refused + url-normalization + derivation ladder

+required_stages = ["impl", "unit"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no-commit + one-commit-multi-key + independent-roots + tier layout/commits + p-branch-aggregates-agents + invalid-id-refused + url-normalization + derivation ladder

# ───────────────────────────── Harness contract — manifest seams ────────────


Add the seed-repo mechanical note to STORAGE.md:


- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task").

- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.


The file C:\Users\decid\Documents\projects\spt-core\docs\STORAGE.md has been updated successfully. (file state is current in your context — no need to Read it back)

-- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task").

+- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.

- **Two-tier sync maps onto the structure:** `agents/<id>/live-context.md` (the `a-` view) syncs to **all** instances of the endpoint; `projects/<project_id>/<id>/project-context.md` (the `p-` view) syncs only to instances sharing that project.

- **P2P replaces the *remote*, not the local git.** Modern SPT pushed this repo to a private GitHub remote; spt-core keeps the local git tracking + branch views and moves the data over the built-in P2P layer instead (no `gh`/account/setup). The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — arbitrates concurrent writes from multiple nodes/instances.


Bash

cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >$null 2>&1; echo "exit=$?"; traceable-reqs check 2>&1 | grep -E "REQ-STORE-1|incomplete|findings" | head -5

Traceability check


/usr/bin/bash: line 4: $null: ambiguous redirect
exit=1
128 requirements: 128 complete, 0 incomplete, 0 findings
  [OK] REQ-STORE-1  required: [impl, unit]  stages: -doc +impl +unit -int


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check > /dev/null 2>&1; echo "exit=$?" && cargo test -p spt-store --lib --quiet 2>&1 | tail -3

Clean traceability exit + re-run tests


exit=0
..............
test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.92s


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git commit -m "feat(store): M4-D6a BranchStore + two-tier ContextStore + project_id (REQ-STORE-1, ADR-0011/0013)

- gitrun.rs: bounded git runner (kill-on-timeout, hazard 5.3); identity pinned
  via env so commits never depend on or pollute user git config; prompts dead
  (GIT_TERMINAL_PROMPT=0).
- branchstore.rs: git branch as versioned KV — bare seed repo, linked
  worktrees (relative-path config + repair fallback), parentless empty-tree
  branch seeds (independent KV roots; sync joins at first contact),
  commit=checkpoint/tip=resume, atomic multi-key commit, no-op writes mint no
  empty checkpoint.
- contextstore.rs: tracked/ realized — agents/<id> worktree of a-<id>
  (live-context.md), projects/<pid> worktree of p-<pid>
  (<id>/project-context.md); ids charset-validated before any branch/dir
  derives from them (4.6).
- project.rs: project_id derivation ladder — remote-URL slug (SSH/HTTPS
  spellings normalize to one id) → git toplevel folder → dir name.
- perch.rs: tracked_dir() path fn (6.1). STORAGE.md: seed-repo mechanics note.
- REQ-STORE-1 activated [impl, unit].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git push

Commit + push D6a-1


warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/STORAGE.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/branchstore.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/contextstore.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/gitrun.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/project.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 8b42452] feat(store): M4-D6a BranchStore + two-tier ContextStore + project_id (REQ-STORE-1, ADR-0011/0013)
 8 files changed, 795 insertions(+), 2 deletions(-)
 create mode 100644 crates/spt-store/src/branchstore.rs
 create mode 100644 crates/spt-store/src/contextstore.rs
 create mode 100644 crates/spt-store/src/gitrun.rs
 create mode 100644 crates/spt-store/src/project.rs
To https://github.com/SaberMage/spt-core.git
   8f4ec24..8b42452  dev-freeform -> dev-freeform


D6a-1 pushed (`8b42452`). Watching CI in background while prepping D6a-2:


cd C:/Users/decid/Documents/projects/spt-core && sleep 150 && gh run list --branch dev-freeform --limit 2 --json conclusion,status,headSha,name

Wait + check CI conclusion for 8b42452