lernie 0.0.4

A git-backed agent harness
Documentation
//! Per-step on-disk landings (ARCH §2.3 / §2.10).
//!
//! Step records live at `<conv-repo>/steps/<conv-id>/<NNN>/`,
//! outside every worktree (§2.2). The harness writes them as
//! diagnostic / audit artifacts and does not read them back at
//! runtime (§2.3 Diagnostic-only contract).
//!
//! Step 1's dispatch commit lays `goal.md` and `soul.md` at the
//! worktree root and commits — that single commit's tree is the
//! model-read state for step 1 (§2.10). Step ≥2 takes no pre-call
//! commit; the branch tip already represents what the model reads.
//! The `commit` field on each step's `meta.json` records that tip
//! sha so replay can re-run context assembly against the right
//! tree (§2.10) without consulting `request.json`.
//!
//! `request.json`, `response.json`, and `meta.json` land outside
//! the worktree and are not git-tracked (§2.3 — "Step records are
//! not committed to git").

mod descriptors;
#[cfg(test)]
mod tests;
mod unsettled;

pub(crate) use descriptors::{Grant, Undescribed, require_described};

use crate::prompt::Deps;
use crate::prompt::Error;
use crate::prompt::step::{META_FILE, REQUEST_FILE, StepMeta};
use serde_json::Value;
use std::path::Path;

/// Worktree-relative path where the conversation's goal is committed
/// at dispatch time (ARCH §2.8). Lives at the worktree root so the
/// manifest's `pinned: [goal.md]` rule (§5.2) sees it.
pub(super) const GOAL_FILE: &str = "goal.md";
/// Worktree-relative path where the role's system prompt is committed
/// at dispatch time (ARCH §4.3 / §2.8). Lives at the worktree root for
/// the same reason `goal.md` does.
pub(super) const SOUL_FILE: &str = "soul.md";

/// `git worktree add -b agents/<id> <worktree_path> <fork-point>`, run
/// against the workspace's bare `repo.git` (§2.2): fork the fresh root
/// agent off the ref the start named — a config lineage's head, or any
/// ref at all (§2.3 *Any ref is a legal fork point*, §7.2
/// fork-from-history). The fork is the freeze (§2.2), and what it
/// freezes is the *governing config commit* of that ref, which
/// `resolved` already carries — so this call is the same operation with
/// a different argument, never a second kind of start. Root id
/// uniqueness per workspace is structural: the `-b` creation fails if
/// the ref already exists.
pub(super) fn spawn_branch(
    workspace: &Path,
    worktree_path: &Path,
    agent_id: &str,
    fork_point: &str,
    deps: &Deps<'_>,
) -> Result<(), Error> {
    let wt_str = worktree_path.to_string_lossy().to_string();
    let branch_ref = crate::workspace::agent_ref(agent_id);
    deps.git
        .run(
            &crate::workspace::repo_git(workspace),
            &[
                "worktree",
                "add",
                "-b",
                branch_ref.as_str(),
                wt_str.as_str(),
                fork_point,
            ],
        )
        .map_err(|source| Error::Git {
            op: "worktree add",
            source,
        })
}

/// Compose the system slot: the branch's goal, the agent's identity when
/// it has a name, then the role's soul. The system slot *is* the
/// pinned-head wire home for `goal.md`, `name` and `soul.md` (§2.3 "Goal
/// and soul are pinned files", §5.2 structural wire homes): assembly
/// composes all three through here, never as body text.
///
/// The goal leads, so it stays pinned at the head of every model call on
/// the branch (§2.8). The identity line is **derived here from the name
/// fact, never stored a second time** (§2.3 — the `name` file is the one
/// home; `docs/PRINCIPLES.md` single source of truth), and it states the
/// name and nothing else: no instruction rides an identity (§2.8 — the
/// name is who the agent is, not what it is to do). An unnamed agent
/// states nothing, and its slot is byte-identical to what a nameless
/// harness composed — the general path with empty inputs, not a second
/// shape.
pub(super) fn compose_system(goal: &str, name: Option<&str>, soul: &str) -> String {
    let identity = name.map_or_else(String::new, |n| format!("Your name is {n}.\n\n"));
    format!("<goal>\n{goal}\n</goal>\n\n{identity}{soul}")
}

/// Step 1: write `goal.md` + `soul.md` to the worktree root, plus any
/// caller-supplied pinned documents at their validated destinations
/// ([`crate::prompt::pinned_doc`], §2.5). Step ≥2 has no dispatch
/// artifact (the branch tip already reflects the model-read state per
/// §2.10).
pub(super) fn write_dispatch_files(
    worktree_path: &Path,
    goal_text: &str,
    soul_text: &str,
    pins: &crate::prompt::PinnedDocs,
) -> Result<(), Error> {
    std::fs::create_dir_all(worktree_path)?;
    std::fs::write(worktree_path.join(GOAL_FILE), goal_text)?;
    std::fs::write(worktree_path.join(SOUL_FILE), soul_text)?;
    pins.write_into(worktree_path)?;
    Ok(())
}

/// Step 1's dispatch commit (§2.3 step 2): remove the harness-facing
/// control files from the agent's tree (§2.2 — control is read from the
/// governing config commit; the worktree holds only context) and settle
/// the agent's `name` (§2.3), `git add goal.md soul.md`, then commit on
/// the agent branch. The removal is
/// total, not conditional: `--ignore-unmatch` makes it a no-op when the
/// fork point was not a config commit (a child forked off a parent's
/// tip, whose tree already lost them). This is the only commit the
/// harness emits for a step; §2.10 keeps step ≥2 commit-free, so the
/// branch tip after a dispatch commit *is* step 1's read state.
pub(super) fn commit_dispatch(
    worktree_path: &Path,
    conv_id: &str,
    name: Option<&str>,
    pins: &crate::prompt::PinnedDocs,
    resolved: &super::Resolved<'_>,
    deps: &Deps<'_>,
) -> Result<(), Error> {
    trim_to_context(worktree_path, &resolved.grant, name, deps.git)?;
    let mut add_args: Vec<&str> = vec!["add", GOAL_FILE, SOUL_FILE];
    add_args.extend(pins.iter().map(crate::prompt::PinnedDoc::dest));
    deps.git
        .run(worktree_path, &add_args)
        .map_err(|source| Error::Git { op: "add", source })?;
    let msg = format!("step 001: dispatch [{conv_id}]");
    deps.git
        .run(worktree_path, &["commit", "-m", msg.as_str()])
        .map_err(|source| Error::Git {
            op: "commit",
            source,
        })
}

/// Stage the trim that makes the forked tree exactly this agent's
/// context (§2.2, §5.1) — one act with four parts, each a no-op when
/// the fork point carried nothing to change, so the primitive is total
/// whatever ref it forked off:
///
/// 1. **Control leaves.** `manifest.yaml`, `workflow.yaml`,
///    `providers.yaml`, `version`, `souls/` — control is read from the
///    governing config commit, never from a worktree file (§2.2).
/// 2. **Descriptors are derived to the grant.** `descriptions/**` is
///    snapshotted whole into the governing config commit (one config
///    commit serves every role), and the agent's tree is the view of it
///    this role's `tools:` grants — checked out from that commit, not
///    inherited from whatever the fork point carried, so a child's
///    descriptors are never capped by its dispatcher's grant.
///    [`descriptors::derive`] does it, and declines a grant the commit
///    does not describe; see that module for the failures it closes.
/// 3. **The unsettled tool step leaves.** A tool-call dispatch forks
///    *during* the parent's tool step (§2.5), so the inherited transcript
///    can end in a `tool_use` block no `tool_result` entry answers — a
///    tail that settles on the parent's branch and never on the child's,
///    and that every provider refuses (§2.5 pairing).
///    [`unsettled::prune_unsettled`] removes exactly it; see that module
///    for the reproduced 400 it closes.
/// 4. **The name is settled.** `name` (ARCH §2.3, §2.11) is this agent's
///    display fact, and a fork inherits its fork point's — so the commit
///    overwrites it with the agent's own, or with nothing when the agent
///    is unnamed. Always a rewrite, never a deletion, for the reasons
///    [`crate::workspace::agent_name`] gives.
///
/// The parts are staged in this order because the later ones read the
/// worktree: control files are neither descriptors nor transcript
/// entries nor the name, so none sees another's writes.
pub(crate) fn trim_to_context(
    worktree_path: &Path,
    grant: &Grant<'_>,
    name: Option<&str>,
    git: &dyn crate::template::GitRunner,
) -> Result<(), Error> {
    let mut args: Vec<&str> = vec!["rm", "-r", "-q", "--ignore-unmatch", "--"];
    args.extend_from_slice(crate::workspace::CONTROL_PATHS);
    git.run(worktree_path, &args).map_err(|source| Error::Git {
        op: "rm control files",
        source,
    })?;
    descriptors::derive(worktree_path, grant, git)?;
    unsettled::prune_unsettled(worktree_path, git)?;
    crate::workspace::agent_name::settle(worktree_path, name, git).map_err(|source| Error::Git {
        op: "settle the agent name",
        source,
    })
}

/// Resolve the branch tip's sha at step-start. Recorded in
/// `meta.json` so replay can re-run context assembly against the
/// right tree without reading `request.json` (§2.10 Diagnostic-only
/// contract).
pub(super) fn read_branch_tip(worktree_path: &Path, deps: &Deps<'_>) -> Result<String, Error> {
    deps.git
        .run_capture(worktree_path, &["rev-parse", "HEAD"])
        .map_err(|source| Error::Git {
            op: "rev-parse",
            source,
        })
}

/// Land `request.json` under `<conv-repo>/steps/<conv-id>/<NNN>/`.
/// Outside every worktree (§2.2) so context assembly cannot pick it
/// up; not git-tracked (§2.3).
pub(super) fn write_request(
    conv_repo: &Path,
    step_dir_rel_str: &str,
    request_value: &Value,
) -> Result<(), Error> {
    let step_dir_abs = conv_repo.join(step_dir_rel_str);
    std::fs::create_dir_all(&step_dir_abs)?;
    let bytes = serde_json::to_vec_pretty(request_value).expect("Value is always serializable");
    std::fs::write(step_dir_abs.join(REQUEST_FILE), bytes)?;
    Ok(())
}

/// Land `meta.json` under the conv-repo step dir. The `commit` field
/// is the load-bearing piece (§2.10 — replay reproduces the wire
/// input by re-running context assembly against this sha).
pub(super) fn write_meta(
    conv_repo: &Path,
    step_dir_rel_str: &str,
    meta: &StepMeta,
) -> Result<(), Error> {
    let step_dir_abs = conv_repo.join(step_dir_rel_str);
    std::fs::create_dir_all(&step_dir_abs)?;
    let bytes = serde_json::to_vec_pretty(meta).expect("StepMeta is always serializable");
    std::fs::write(step_dir_abs.join(META_FILE), bytes)?;
    Ok(())
}