yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Short-verb dispatchers + the `ops.jsonl` wiring (DESIGN §8.2, §15 Y16).
//!
//! The action surface's *short, piped* verbs — every one but the detached
//! `lernie prompt` (§8.1, Y17). Per §8.2 each runs synchronously with stdout
//! and stderr piped, then appends its completed outcome to `ops.jsonl`
//! (§4.2) — replacing the legacy "stderr printed and dropped" spawn-and-drain.
//!
//! | verb | argv | cwd |
//! |---|---|---|
//! | message | `lernie message <ws> <agent> <text>` (env `YOG_NAME=<ws-leaf>`) | ws |
//! | stop | `lernie stop <ws> <agent> [--stop-children]` | ws |
//! | scan | `lernie scan <ws>` | ws |
//! | close | `bl close <id> --as <name>` | project |
//! | assign | `bl claim <id> --as <name>` | project |
//! | move | `bl unclaim <id> --as <from>` then `bl claim <id> --as <to>` | project |
//! | release / unclaim | `bl unclaim <id> --as <name>` | project |
//! | create | `bl create <title> --as <name> [--body B]` | project |
//! | update | `bl update <id> --as <name> [--title T][--body B][-m N]` | project |
//!
//! Every verb runs in an explicit cwd — the `bl` verbs against the project
//! (§8.2), the `lernie` verbs against the workspace (harmless — lernie takes
//! the ws as argv — and a truthful `cwd` field). One invariant, no per-verb
//! special-case. `create`'s captured id is just its [`Outcome::stdout`] (bl
//! prints the new id there). The `ts` stamp is minted at the shell boundary and
//! injected, keeping this path pure-otherwise and deterministic in tests.
//!
//! **§8.2 identity rider (Z4):** every `bl` claim/close/unclaim is stamped `--as
//! <workspace name>`, **not** the operator `$USER` — the claimant delivers its own
//! ball (§3.2). Close/release stamp the ball's *bound* name; assign and a move's
//! claim the *target* name; a move's unclaim the *source* name. `message` layers
//! `YOG_NAME=<ws leaf>` on the revived driver so its agent tools stamp `--as` the
//! same name (§8/§3.3, W9). Enablement predicates live in [`super`](crate::actions).
//!
//! **The capability gate is consulted here (§16.4 / §16.6 W5).** Every mutating
//! verb takes the classified [`ToolchainState`] and calls [`gate_check`] before
//! spawning: on a `Mismatch`/`Missing` for the tool it needs it appends a
//! `["yog-step","gate"]` ops row (the refusal a rendered fact) and returns the
//! typed [`Refusal`](crate::world::toolgate::Refusal) — no spawn. The shell's
//! greying only mirrors this dispatch-layer gate; `start::prepare` gates the
//! composite start verb identically; the read path is never gated.

use std::io;
use std::path::Path;

use crate::cli_outbound::{Binary, Cli};
use crate::world::toolgate::ToolchainState;

mod dispatch;
pub use dispatch::{Outcome, log_step_failure, run_logged, run_logged_cwdless};
// `collect` stays crate-internal — the no-marks knob's `bl conf` seam reuses it.
pub(crate) use dispatch::collect;

/// The `["yog-step",<step>]` name for a gate refusal's ops row (§4.2).
const GATE_STEP: &str = "gate";

/// Consult the phase-1 capability gate before a mutating dispatch (§16.4 W5): on a
/// `Mismatch`/`Missing` verdict for `binary`, append a `["yog-step","gate"]` ops
/// row (Z5's [`log_step_failure`]) — so the refusal is a rendered fact — and hand
/// back the typed refusal as the verb's error; `Ok(())` lets the spawn proceed.
fn gate_check(
    gate: &ToolchainState,
    binary: Binary,
    state_root: &Path,
    ts: &str,
    cwd: &Path,
) -> io::Result<()> {
    match gate.require(binary) {
        Ok(()) => Ok(()),
        Err(refusal) => {
            log_step_failure(state_root, ts, cwd, GATE_STEP, &refusal.to_string())?;
            Err(io::Error::other(refusal))
        }
    }
}

// lernie subcommands (pinned to `src/bin/lernie.rs`, §8.2).
const MESSAGE: &str = "message";
const STOP: &str = "stop";
const SCAN: &str = "scan";
const STOP_CHILDREN: &str = "--stop-children";
// bl subcommands (pinned to `bl <verb> --skill`, §8.2).
const CLOSE: &str = "close";
const CLAIM: &str = "claim";
const UNCLAIM: &str = "unclaim";
const CREATE: &str = "create";
const UPDATE: &str = "update";
const AS: &str = "--as";
const TITLE: &str = "--title";
const BODY: &str = "--body";
const NOTE: &str = "-m";
/// The workspace-scoped identity env (§8/§3.3): the W9 shim stamps `--as` onto a
/// revived driver's unstamped bl verbs from it.
const YOG_NAME: &str = "YOG_NAME";

/// The workspace name a `bl` claim/close/unclaim or `YOG_NAME` layer stamps
/// (§3.2/§8): the workspace directory's leaf. Empty for a rootless path (never a
/// real workspace).
fn ws_name(ws: &Path) -> String {
    ws.file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default()
}

/// `lernie message <ws> <agent> <content>` — the resume gesture (§8.2, ARCH
/// §2.9: no resume verb exists; the deposit restarts a driver). The revived
/// driver is a **workspace-scoped spawn**, so it carries `YOG_NAME=<ws leaf>`
/// (§8/§3.3): its agents' own tool subprocesses stamp `--as <name>` (the W9 shim)
/// exactly as the detached `lernie prompt` does (Z3), the addendum's fix.
pub fn message(
    gate: &ToolchainState,
    lernie: &Cli,
    state_root: &Path,
    ts: &str,
    ws: &Path,
    agent: &str,
    content: &str,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Lernie, state_root, ts, ws)?;
    let ws_s = ws.to_string_lossy();
    let named = lernie.and_env(vec![(YOG_NAME.to_owned(), ws_name(ws))]);
    run_logged(
        &named,
        state_root,
        ts,
        ws,
        &[MESSAGE, &ws_s, agent, content],
    )
}

/// `lernie stop <ws> <agent> [--stop-children]` — the §2.9 SIGTERM cascade,
/// optionally to the agent's descendants (§8.2).
pub fn stop(
    gate: &ToolchainState,
    lernie: &Cli,
    state_root: &Path,
    ts: &str,
    ws: &Path,
    agent: &str,
    stop_children: bool,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Lernie, state_root, ts, ws)?;
    let ws_s = ws.to_string_lossy();
    let mut args = vec![STOP, ws_s.as_ref(), agent];
    if stop_children {
        args.push(STOP_CHILDREN);
    }
    run_logged(lernie, state_root, ts, ws, &args)
}

/// `lernie scan <ws>` — flush inboxes and deposit died epitaphs (§8.2, §7.3).
pub fn scan(
    gate: &ToolchainState,
    lernie: &Cli,
    state_root: &Path,
    ts: &str,
    ws: &Path,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Lernie, state_root, ts, ws)?;
    let ws_s = ws.to_string_lossy();
    run_logged(lernie, state_root, ts, ws, &[SCAN, &ws_s])
}

/// `bl close <id> --as <name>` in the project (§8.2): fold/gate/squash; gate
/// failures ride back verbatim in the returned [`Outcome`] (claim + the
/// bl-delivery worktree stay up — bl's own semantics). `name` is the ball's
/// **bound workspace name** — the claimant delivers its own ball (§3.2 rider),
/// never the operator `$USER`.
pub fn close(
    gate: &ToolchainState,
    bl: &Cli,
    state_root: &Path,
    ts: &str,
    project: &Path,
    id: &str,
    name: &str,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Bl, state_root, ts, project)?;
    run_logged(bl, state_root, ts, project, &[CLOSE, id, AS, name])
}

/// `bl claim <id> --as <name>` in the project — **assign** a ready ball to a
/// workspace (§8.2/§3.2): the late-mutable binding as a first-class verb, stamped
/// with the *target* workspace name. Distinct from the start flow's claim
/// ([`crate::start::execute_claim`]): assign only binds, it starts no conversation
/// and needs no worktree cross-check. Gated like every mutating `bl` verb (§16.4).
pub fn assign(
    gate: &ToolchainState,
    bl: &Cli,
    state_root: &Path,
    ts: &str,
    project: &Path,
    id: &str,
    name: &str,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Bl, state_root, ts, project)?;
    run_logged(bl, state_root, ts, project, &[CLAIM, id, AS, name])
}

/// `bl unclaim <id> --as <name>` in the project — **release** (§8.2/§3.2),
/// stamped with the ball's bound workspace name.
pub fn unclaim(
    gate: &ToolchainState,
    bl: &Cli,
    state_root: &Path,
    ts: &str,
    project: &Path,
    id: &str,
    name: &str,
) -> io::Result<Outcome> {
    gate_check(gate, Binary::Bl, state_root, ts, project)?;
    run_logged(bl, state_root, ts, project, &[UNCLAIM, id, AS, name])
}

/// **Move** a ball to another workspace (§8.2/§3.2): `bl unclaim <id> --as <from>`
/// then `bl claim <id> --as <to>`, in the project — the source workspace releases
/// its own ball, the target claims it, both logged (§8.2 "short, piped ×2, both
/// logged"), both gated (§16.4). Returns the claim's [`Outcome`]; a spawn failure
/// (or gate refusal) of the unclaim aborts before the claim. `stamp` is the ops-log
/// `(state_root, ts)` pair — bundled (as in [`create`]) so the two extra endpoints
/// fit the arg-count cap.
pub fn reassign(
    gate: &ToolchainState,
    bl: &Cli,
    stamp: (&Path, &str),
    project: &Path,
    id: &str,
    from: &str,
    to: &str,
) -> io::Result<Outcome> {
    let (state_root, ts) = stamp;
    unclaim(gate, bl, state_root, ts, project, id, from)?;
    assign(gate, bl, state_root, ts, project, id, to)
}

/// `bl create <title> --as <name> [--body B]` in the project (§8.2). The new id
/// is [`Outcome::stdout`] (bl prints it there for `id=$(bl create …)`); `name` is
/// the authoring workspace (the start flow passes the minted/focused name).
/// `stamp` is the ops-log `(state_root, ts)` pair — bundled (with `update`'s) so
/// the gate arg fits the arg-count cap without a borrow-holding context struct.
pub fn create(
    gate: &ToolchainState,
    bl: &Cli,
    stamp: (&Path, &str),
    project: &Path,
    title: &str,
    name: &str,
    body: Option<&str>,
) -> io::Result<Outcome> {
    let (state_root, ts) = stamp;
    gate_check(gate, Binary::Bl, state_root, ts, project)?;
    let mut args = vec![CREATE, title, AS, name];
    if let Some(b) = body {
        args.push(BODY);
        args.push(b);
    }
    run_logged(bl, state_root, ts, project, &args)
}

/// The field edits `bl update` carries from the ball editor (§11 ball detail):
/// a retitle, a body rewrite (the living document), and/or a journal note. All
/// optional — an all-`None` update still restamps `updated` (bl's note commit).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Update {
    pub title: Option<String>,
    pub body: Option<String>,
    pub note: Option<String>,
}

/// `bl update <id> --as <name> [--title T] [--body B] [-m NOTE]` in the project
/// (§8.2), carrying only the fields the operator changed. `stamp` is the ops-log
/// `(state_root, ts)` pair (bundled as in [`create`], for the arg cap).
pub fn update(
    gate: &ToolchainState,
    bl: &Cli,
    stamp: (&Path, &str),
    project: &Path,
    id: &str,
    name: &str,
    fields: &Update,
) -> io::Result<Outcome> {
    let (state_root, ts) = stamp;
    gate_check(gate, Binary::Bl, state_root, ts, project)?;
    let mut args = vec![UPDATE, id, AS, name];
    if let Some(t) = &fields.title {
        args.push(TITLE);
        args.push(t);
    }
    if let Some(b) = &fields.body {
        args.push(BODY);
        args.push(b);
    }
    if let Some(n) = &fields.note {
        args.push(NOTE);
        args.push(n);
    }
    run_logged(bl, state_root, ts, project, &args)
}

#[cfg(test)]
mod tests;