litany 0.0.12

A git-backed agent harness
Documentation
//! The **skill census** — the derivation behind `litany skills`
//! (`docs/DESIGN_LEARNING_LOOP.md` §5, "the curator is a query").
//!
//! No store, no counter, no curator process: every fact a row carries is
//! already a commit, and git already dates it. A skill's **last use** is
//! the newest commit on an `agents/*` branch that *added*
//! `skills/<name>/` — the `load_skill` copy is the use (ARCH §3.3) — and
//! its **last patch** is the newest `config/*` commit touching that path
//! in either of its two homes, `skills/<name>/` or the archive container
//! `skills/archived/<name>/`.
//!
//! **Two exclusions make the use query mean what it says.** The walk runs
//! `--branches=agents/*` so a deleted agent's history is gone with its
//! ref — "living branch" needs no second derivation — and `--not
//! --branches=config/*` so the config commit that *authored* a workspace
//! skill is not read as every descendant agent's election. What is left
//! is exactly the adds an agent branch made on its own.
//!
//! **Ages come from git** (`%cr`), not from a clock this module reads:
//! the age is what the reader wants and the horizon is the reader's
//! (§5 refuses a stored `stale` state). `%ct` rides beside it for the
//! ordering — oldest-used first, never-used first of all.

use crate::template::{GitRunner, descriptions};
use crate::workspace::{self, SKILLS_DIR};
use std::collections::BTreeSet;
use std::path::Path;

/// `git log`'s answer for one row: the sortable stamp and the rendered
/// age, taken in one walk so the two can never disagree.
const FORMAT: &str = "--format=%ct %cr";
/// The archive container's name, reserved in both homes.
const ARCHIVED: &str = descriptions::ARCHIVED_SUBDIR;

/// Which home holds the body — ownership is the path (ARCH §3.3).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Owner {
    /// `<data-root>/skills/<name>/`, shared by every workspace on the box.
    Pool,
    /// `skills/<name>/` in the config lineage, versioned and forkable.
    Workspace,
}

/// The four states of §5. There is no `stale`: a wall-clock horizon is
/// policy, policy is config, and this verb adds none.
///
/// **One derivation of "loaded" for both owners** (bl-4a4a). `active`
/// says a living branch elected it and nothing else, so the state and
/// the LAST USE column can never disagree: `active` is exactly the row
/// that carries a date. What used to hide inside `active` — a tool
/// claims the name, so the body's description composes on every model
/// call without any election — is `claimed`, its own word, because it
/// is its own evidence. Without it a pool built-in and a never-loaded
/// workspace skill read `active` and `unused` off identical columns.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum State {
    /// A living `agents/*` branch has loaded it.
    Active,
    /// No branch has loaded it, but a tool claims the name, so it
    /// composes as that tool's `description` on every model call
    /// (ARCH §3.3) and can never report a use.
    Claimed,
    /// Neither: no living branch carries it and no tool claims it.
    Unused,
    /// `skills/archived/<name>/` in the followed config commit. Composes
    /// nowhere and cannot be named by `load_skill`.
    Archived,
}

/// One commit's date, twice: sortable seconds and git's own rendering.
#[derive(Debug)]
pub(crate) struct Stamp {
    secs: i64,
    age: String,
}

/// One census row.
#[derive(Debug)]
pub(crate) struct Row {
    pub(crate) name: String,
    pub(crate) owner: Owner,
    pub(crate) state: State,
    last_use: Option<Stamp>,
    last_patch: Option<Stamp>,
}

/// Every skill both homes offer, one row each, oldest-used first.
/// `commit` is the config commit the workspace skills and the archive
/// container are read from; `data_root` is the install root whose
/// `skills/` pool is the other home.
pub(crate) fn census(ws: &Path, commit: &str, data_root: &Path, git: &dyn GitRunner) -> Vec<Row> {
    let archived = tree_names(ws, commit, &format!("{SKILLS_DIR}/{ARCHIVED}"), git);
    let mut committed = tree_names(ws, commit, SKILLS_DIR, git);
    committed.remove(ARCHIVED);
    let pooled = pool_names(&data_root.join(descriptions::SKILLS_SUBDIR));
    let mut rows: Vec<Row> = archived
        .iter()
        .map(|n| (n, Owner::Workspace))
        .chain(committed.iter().map(|n| (n, Owner::Workspace)))
        .chain(pooled.iter().map(|n| (n, Owner::Pool)))
        .map(|(name, owner)| row(ws, commit, name, owner, &archived, git))
        .collect();
    order(&mut rows);
    rows
}

/// Oldest-used first, a never-used skill oldest of all (`None` precedes
/// `Some`), then by name so the table is stable across runs. Its own
/// function because a wall clock is a bad thing to prove an ordering
/// with: the fixture that constructs the stamps proves this, and the
/// fixtures that run real git prove where a stamp comes from.
fn order(rows: &mut [Row]) {
    rows.sort_by(|a, b| key(a).cmp(&key(b)));
}

/// One row's ordering key ([`order`]).
fn key(r: &Row) -> (Option<i64>, &str) {
    (r.last_use.as_ref().map(|s| s.secs), r.name.as_str())
}

/// Build one row: the two dates, then the state they and the tree decide.
fn row(
    ws: &Path,
    commit: &str,
    name: &str,
    owner: Owner,
    archived: &BTreeSet<String>,
    git: &dyn GitRunner,
) -> Row {
    let body = format!("{SKILLS_DIR}/{name}");
    let stored = format!("{SKILLS_DIR}/{ARCHIVED}/{name}");
    let last_use = stamp(
        ws,
        git,
        &[
            "log",
            "--diff-filter=A",
            "--max-count=1",
            FORMAT,
            "--branches=agents/*",
            "--not",
            "--branches=config/*",
            "--",
            &body,
        ],
    );
    let last_patch = stamp(
        ws,
        git,
        &[
            "log",
            "--max-count=1",
            FORMAT,
            "--branches=config/*",
            "--",
            &body,
            &stored,
        ],
    );
    let state = if archived.contains(name) {
        State::Archived
    } else if last_use.is_some() {
        State::Active
    } else if tool_claimed(ws, commit, name, git) {
        State::Claimed
    } else {
        State::Unused
    };
    Row {
        name: name.to_owned(),
        owner,
        state,
        last_use,
        last_patch,
    }
}

/// Is a tool schema committed beside the skill's description? A claimed
/// skill composes as that tool's `description` on every model call
/// (ARCH §3.3), so it is never idle — and never elected either, which is
/// why §5 gives it a word of its own rather than `unused` or `active`.
fn tool_claimed(ws: &Path, commit: &str, name: &str, git: &dyn GitRunner) -> bool {
    let path = format!(
        "{}/{}/{name}.json",
        descriptions::DESCRIPTIONS_DIR,
        descriptions::TOOLS_SUBDIR
    );
    workspace::control_exists(ws, commit, &path, git)
}

/// One `git log` walk's newest answer, or `None` when the walk names no
/// commit — an unreadable ref set, an empty workspace and a path no
/// commit ever carried are one answer, not three.
fn stamp(ws: &Path, git: &dyn GitRunner, args: &[&str]) -> Option<Stamp> {
    let out = git
        .run_capture(&workspace::repo_git(ws), args)
        .unwrap_or_default();
    let (secs, age) = out.lines().next()?.trim().split_once(' ')?;
    Some(Stamp {
        secs: secs.parse().ok()?,
        age: age.to_owned(),
    })
}

/// The entry names of a tree inside a commit; an absent tree is empty.
fn tree_names(ws: &Path, commit: &str, path: &str, git: &dyn GitRunner) -> BTreeSet<String> {
    let spec = format!("{commit}:{path}");
    git.run_capture(&workspace::repo_git(ws), &["ls-tree", "--name-only", &spec])
        .unwrap_or_default()
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .map(str::to_owned)
        .collect()
}

/// The install pool's skill directories; a missing pool is empty (§3.3).
fn pool_names(pool: &Path) -> BTreeSet<String> {
    match std::fs::read_dir(pool) {
        Ok(rd) => rd
            .flatten()
            .filter(|e| e.path().is_dir())
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .filter(|n| n != ARCHIVED)
            .collect(),
        Err(_) => BTreeSet::new(),
    }
}

mod render;
pub(crate) use render::render;

#[cfg(test)]
mod tests;