ritalin 0.4.5

Executive function for AI coding agents. Focus their intelligence, ground their work, stop the avoidable mistakes.
/// On-disk state for ritalin contracts.
///
/// Layout (relative to current working directory):
///   .ritalin/scope.yaml         — human-edited contract (outcome + metadata)
///   .ritalin/obligations.jsonl  — append-only obligation ledger
///   .ritalin/evidence.jsonl     — append-only verification evidence ledger
///   .task-incomplete            — marker file; presence = "agent must keep working"
///
/// Why JSONL for ledgers? Append-only writes are atomic line-by-line on POSIX,
/// so we never corrupt the ledger even on crash. No locking needed for the
/// single-builder case; multi-writer scenarios should serialize through `gate`.
///
/// Why YAML for scope? Humans (and agents) read and write it directly;
/// JSON's lack of comments makes it hostile to in-line acceptance criteria.
pub mod evidence;
pub mod marker;
pub mod obligations;
pub mod scope;
pub mod workspace_hash;

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

use crate::error::AppError;

/// Walk up from `cwd` looking for a `.ritalin/` directory, without crossing
/// into a *different* project's territory. A directory containing a `.git`
/// directory but no `.ritalin` is an independent repo root — the walk stops
/// there, so a stray `.ritalin` in e.g. `~/Projects` cannot capture the Stop
/// hook of every repo nested beneath it. A `.git` *file* (submodule or
/// linked worktree) is subordinate to a parent checkout by construction, so
/// the walk continues — `cd`-ing into a submodule of the contract's repo
/// must still find the contract. Outside any git repo the walk continues to
/// the filesystem root (pre-v0.4.5 behavior).
fn find_state_dir(cwd: &Path) -> Option<PathBuf> {
    let mut current = Some(cwd);
    while let Some(dir) = current {
        let candidate = dir.join(".ritalin");
        if candidate.exists() {
            return Some(candidate);
        }
        if dir.join(".git").is_dir() {
            return None;
        }
        current = dir.parent();
    }
    None
}

/// Acquire the exclusive contract lock (`.ritalin/.lock`), serializing id
/// allocation (`add`), destructive rewrites (`init`/`seed --force`), and
/// gate's pass-commit against each other. The lock lives on a dedicated
/// file that is never deleted — locking the ledger itself would guard
/// nothing once `--force` unlinks and recreates it, and append-only handles
/// cannot be locked on Windows. Advisory; released when the returned handle
/// drops (including on crash).
pub fn lock_state(state_dir: &Path) -> Result<std::fs::File, AppError> {
    std::fs::create_dir_all(state_dir)?;
    let file = std::fs::OpenOptions::new()
        .create(true)
        .truncate(false)
        .write(true)
        .open(state_dir.join(".lock"))?;
    file.lock()?;
    Ok(file)
}

/// Find the ritalin state directory by walking up from cwd (stopping at the
/// git-repo boundary). Returns `.ritalin/` next to the first ancestor that
/// contains it, or `<cwd>/.ritalin/` if none is found.
pub fn state_dir(cwd: &Path) -> PathBuf {
    find_state_dir(cwd).unwrap_or_else(|| cwd.join(".ritalin"))
}

/// Returns true if `.ritalin/` exists in the cwd ancestry within the current
/// git repo (or anywhere up the tree when not in a git repo).
pub fn is_initialized(cwd: &Path) -> bool {
    find_state_dir(cwd).is_some()
}