nexo-driver-loop 0.1.5

Goal orchestrator + LlmDecider + Unix socket bridge for the nexo-rs driver subsystem. Phase 67.4.
Documentation
//! `nexo-driver` CLI — runs goals defined in YAML against the
//! configured DriverOrchestrator.
//!
//! Usage:
//!   nexo-driver run <goal-yaml> [--config <claude.yaml>] [--no-events]
//!   nexo-driver list-active     [--config <claude.yaml>]
//!
//! Exit codes:
//!   0  Done
//!   1  BudgetExhausted | Escalate | NeedsRetry
//!   2  Cancelled | Continue | DriverError

use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;

use anyhow::{anyhow, Result};
use nexo_driver_claude::{MemoryBindingStore, SessionBindingStore, SqliteBindingStore};
use nexo_driver_loop::{
    AcceptanceEvaluator, BindingStoreKind, DeciderConfig, DefaultAcceptanceEvaluator, DriverConfig,
    DriverOrchestrator, GitWorktreeMode, NoopEventSink, WorkspaceManager,
};
use nexo_driver_permission::{AllowAllDecider, DenyAllDecider, PermissionDecider};
use nexo_driver_types::DefaultCompactPolicy;
use nexo_driver_types::Goal;

#[tokio::main]
async fn main() -> ExitCode {
    tracing_subscriber::fmt::init();
    match run().await {
        Ok(code) => code,
        Err(e) => {
            eprintln!("nexo-driver: {e:#}");
            ExitCode::from(2)
        }
    }
}

async fn run() -> Result<ExitCode> {
    let mut args = std::env::args().skip(1);
    let cmd = args
        .next()
        .ok_or_else(|| anyhow!("missing subcommand (run | list-active)"))?;
    match cmd.as_str() {
        "run" => cmd_run(args).await,
        "list-active" => cmd_list_active(args).await,
        "list-worktrees" => cmd_list_worktrees(args).await,
        "rollback" => cmd_rollback(args).await,
        "-h" | "--help" => {
            print_help();
            Ok(ExitCode::SUCCESS)
        }
        other => Err(anyhow!("unknown subcommand: {other}")),
    }
}

fn print_help() {
    eprintln!(
        "nexo-driver run <goal-yaml> [--config <claude.yaml>] [--no-events]\n\
         nexo-driver list-active [--config <claude.yaml>]\n\
         nexo-driver list-worktrees [--config <claude.yaml>]\n\
         nexo-driver rollback <goal-id> --to <sha> [--config <claude.yaml>]"
    );
}

async fn cmd_list_worktrees(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
    let mut config_path: Option<PathBuf> = None;
    while let Some(a) = args.next() {
        match a.as_str() {
            "--config" => {
                config_path = Some(
                    args.next()
                        .ok_or_else(|| anyhow!("--config requires path"))?
                        .into(),
                );
            }
            other => return Err(anyhow!("unknown flag: {other}")),
        }
    }
    let cfg = load_config(config_path.as_deref())?;
    if !cfg.workspace.git.enabled {
        println!("git mode disabled in config — nothing to list");
        return Ok(ExitCode::SUCCESS);
    }
    let Some(source_repo) = cfg.workspace.git.source_repo.clone() else {
        println!("workspace.git.source_repo missing — nothing to list");
        return Ok(ExitCode::SUCCESS);
    };
    use nexo_driver_loop::ShellRunner;
    let shell = ShellRunner::default();
    let res = shell
        .run(
            &format!(
                "git -C {} worktree list --porcelain",
                shell_escape(&source_repo.display().to_string())
            ),
            &source_repo,
            std::time::Duration::from_secs(15),
        )
        .await
        .map_err(|e| anyhow!("git worktree list failed: {e}"))?;
    if res.exit_code != Some(0) {
        return Err(anyhow!("git worktree list exit {:?}", res.exit_code));
    }
    let mut worktree: Option<String> = None;
    let mut head: Option<String> = None;
    for line in res.stdout.lines() {
        if let Some(p) = line.strip_prefix("worktree ") {
            worktree = Some(p.to_string());
            head = None;
        } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
            if b.starts_with("nexo-driver/") {
                if let (Some(w), Some(h)) = (worktree.as_ref(), head.as_ref()) {
                    println!("{w}\t{b}\t{}", &h[..h.len().min(7)]);
                }
            }
        } else if let Some(h) = line.strip_prefix("HEAD ") {
            head = Some(h.to_string());
        }
    }
    Ok(ExitCode::SUCCESS)
}

async fn cmd_rollback(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
    let goal_id = args
        .next()
        .ok_or_else(|| anyhow!("rollback: <goal-id> required"))?;
    let mut to_sha: Option<String> = None;
    let mut config_path: Option<PathBuf> = None;
    while let Some(a) = args.next() {
        match a.as_str() {
            "--to" => {
                to_sha = Some(args.next().ok_or_else(|| anyhow!("--to requires <sha>"))?);
            }
            "--config" => {
                config_path = Some(
                    args.next()
                        .ok_or_else(|| anyhow!("--config requires path"))?
                        .into(),
                );
            }
            other => return Err(anyhow!("unknown flag: {other}")),
        }
    }
    let sha = to_sha.ok_or_else(|| anyhow!("rollback: --to <sha> required"))?;
    let cfg = load_config(config_path.as_deref())?;
    let wm = build_workspace_manager(&cfg)?;
    let workspace = cfg.workspace.root.join(&goal_id);
    wm.rollback(&workspace, &sha).await?;
    println!("rollback ok: {goal_id}{sha}");
    Ok(ExitCode::SUCCESS)
}

fn shell_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for ch in s.chars() {
        if ch == '\'' {
            out.push_str("'\\''");
        } else {
            out.push(ch);
        }
    }
    out.push('\'');
    out
}

async fn cmd_run(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
    let goal_path = args
        .next()
        .ok_or_else(|| anyhow!("run: <goal-yaml> required"))?;
    let mut config_path: Option<PathBuf> = None;
    let mut no_events = false;
    while let Some(a) = args.next() {
        match a.as_str() {
            "--config" => {
                config_path = Some(
                    args.next()
                        .ok_or_else(|| anyhow!("--config requires path"))?
                        .into(),
                );
            }
            "--no-events" => no_events = true,
            other => return Err(anyhow!("unknown flag: {other}")),
        }
    }
    let cfg = load_config(config_path.as_deref())?;
    let goal_raw = std::fs::read_to_string(&goal_path)?;
    let goal: Goal = serde_yaml::from_str(&goal_raw).map_err(|e| anyhow!("goal yaml: {e}"))?;
    if goal.id.0.is_nil() {
        return Err(anyhow!("goal id must not be the nil UUID"));
    }
    let orchestrator = build_orchestrator(&cfg, no_events).await?;
    let outcome = orchestrator.run_goal(goal).await?;
    let json = serde_json::to_string_pretty(&outcome)?;
    println!("{json}");
    let code = match outcome.outcome {
        nexo_driver_types::AttemptOutcome::Done => ExitCode::SUCCESS,
        nexo_driver_types::AttemptOutcome::BudgetExhausted { .. }
        | nexo_driver_types::AttemptOutcome::Escalate { .. }
        | nexo_driver_types::AttemptOutcome::NeedsRetry { .. } => ExitCode::from(1),
        _ => ExitCode::from(2),
    };
    let _ = orchestrator.shutdown().await;
    Ok(code)
}

async fn cmd_list_active(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
    let mut config_path: Option<PathBuf> = None;
    while let Some(a) = args.next() {
        match a.as_str() {
            "--config" => {
                config_path = Some(
                    args.next()
                        .ok_or_else(|| anyhow!("--config requires path"))?
                        .into(),
                );
            }
            other => return Err(anyhow!("unknown flag: {other}")),
        }
    }
    let cfg = load_config(config_path.as_deref())?;
    let store = open_binding_store(&cfg.binding_store).await?;
    let active = store.list_active().await?;
    println!("{}", serde_json::to_string_pretty(&active)?);
    Ok(ExitCode::SUCCESS)
}

fn build_workspace_manager(cfg: &DriverConfig) -> Result<WorkspaceManager> {
    let mut wm = WorkspaceManager::new(&cfg.workspace.root);
    if cfg.workspace.git.enabled {
        let path = cfg
            .workspace
            .git
            .source_repo
            .clone()
            .ok_or_else(|| anyhow!("workspace.git.enabled=true but source_repo missing"))?;
        wm = wm.with_git(GitWorktreeMode::SourceRepo {
            path,
            base_ref: cfg.workspace.git.base_ref.clone(),
        });
    }
    Ok(wm)
}

fn load_config(path: Option<&std::path::Path>) -> Result<DriverConfig> {
    let path = path
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("NEXO_DRIVER_CONFIG").map(PathBuf::from))
        .unwrap_or_else(|| PathBuf::from("config/driver/claude.yaml"));
    Ok(DriverConfig::from_yaml_file(&path)?)
}

async fn open_binding_store(
    cfg: &nexo_driver_loop::BindingStoreConfig,
) -> Result<Arc<dyn SessionBindingStore>> {
    match cfg.kind {
        BindingStoreKind::Memory => {
            let s: Arc<dyn SessionBindingStore> = Arc::new(MemoryBindingStore::new());
            Ok(s)
        }
        BindingStoreKind::Sqlite => {
            let path = cfg
                .path
                .clone()
                .ok_or_else(|| anyhow!("sqlite binding_store requires path"))?;
            let mut store =
                SqliteBindingStore::open(path.to_str().ok_or_else(|| anyhow!("bad path"))?).await?;
            if let Some(t) = cfg.idle_ttl {
                store = store.with_idle_ttl(t);
            }
            if let Some(a) = cfg.max_age {
                store = store.with_max_age(a);
            }
            let s: Arc<dyn SessionBindingStore> = Arc::new(store);
            Ok(s)
        }
    }
}

async fn build_orchestrator(cfg: &DriverConfig, no_events: bool) -> Result<DriverOrchestrator> {
    let binding_store = open_binding_store(&cfg.binding_store).await?;
    let workspace_manager = Arc::new(build_workspace_manager(cfg)?);
    let decider: Arc<dyn PermissionDecider> = match &cfg.permission.decider {
        DeciderConfig::AllowAll => Arc::new(AllowAllDecider),
        DeciderConfig::DenyAll { reason } => Arc::new(DenyAllDecider {
            reason: reason.clone(),
        }),
        DeciderConfig::Llm { .. } => {
            return Err(anyhow!(
                "DeciderConfig::Llm not yet wired here (needs llm config from broker layer)"
            ));
        }
    };
    let event_sink: Arc<dyn nexo_driver_loop::DriverEventSink> =
        if no_events || !cfg.driver.emit_nats_events {
            Arc::new(NoopEventSink)
        } else {
            // 67.4 ships emission via NoopEventSink by default in CLI;
            // an external runner that wants NATS will construct the
            // orchestrator programmatically with NatsEventSink.
            Arc::new(NoopEventSink)
        };
    // 67.5 — wire the real acceptance evaluator with operator-supplied
    // shell timeout / evidence cap, plus the two built-in custom
    // verifiers (no_paths_touched, git_clean).
    let mut acceptance = DefaultAcceptanceEvaluator::new();
    if let Some(t) = cfg.acceptance.default_shell_timeout {
        acceptance = acceptance.with_default_shell_timeout(t);
    }
    if let Some(n) = cfg.acceptance.evidence_byte_limit {
        acceptance = acceptance.with_evidence_byte_limit(n);
    }
    let acceptance: Arc<dyn AcceptanceEvaluator> = Arc::new(acceptance);

    let compact_policy: Arc<dyn nexo_driver_types::CompactPolicy> =
        Arc::new(DefaultCompactPolicy {
            enabled: cfg.compact_policy.enabled,
            threshold: cfg.compact_policy.threshold,
            min_turns_between: cfg.compact_policy.min_turns_between_compacts,
            max_consecutive_failures: cfg
                .compact_policy
                .auto
                .as_ref()
                .map(|a| a.max_consecutive_failures)
                .unwrap_or(3),
        });

    let mut orch_builder = DriverOrchestrator::builder()
        .claude_config(cfg.claude.clone())
        .binding_store(binding_store)
        .acceptance(acceptance)
        .decider(decider)
        .workspace_manager(workspace_manager)
        .event_sink(event_sink)
        .compact_policy(compact_policy)
        .compact_context_window(cfg.compact_policy.context_window)
        .bin_path(cfg.driver.bin_path.clone())
        .socket_path(cfg.permission.socket.clone());
    if let Some(ref auto) = cfg.compact_policy.auto {
        orch_builder = orch_builder.auto_config(auto.clone());
    }
    let orch = orch_builder.build().await?;
    Ok(orch)
}