yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The composite "start a conversation" verb (DESIGN §3.4, §8.1, §15 M6 Z3):
//! **a pure planner + a step executor**, the one flow that turns Enter-in-a-box
//! into a running lernie loop.
//!
//! §3.4's two orthogonal axes, one composer. *Where* a prompt goes — the
//! [`Target`] workspace: the focused one, or, in a world with zero workspaces, a
//! freshly **minted** name (bootstrap is the empty case of the general path,
//! never a wizard). *What* it carries — the [`Payload`] rung: **bare** (an empty
//! composer), **path** (a work directory), or **ball** (a picked/created ball).
//!
//! [`plan`] is the pure planner: given the resolved [`PlanInputs`] (the target
//! *name* and the payload) it returns the ordered [`Step`] sequence — the amended
//! §8.1 order **seed → `lernie new` → `bl` mutations → prompt**, so every
//! substrate step precedes every `bl` mutation and a failed substrate can never
//! mint an orphaned claim. Every step is idempotent-or-convergent, so re-running
//! `plan` after a partial failure yields the shorter remainder: a bound ball
//! drops [`Step::Claim`], an existing workspace's [`Step::EnsureWorkspace`] is a
//! no-op skip. A **new** ball defers its id to a single [`Step::Create`]; the
//! executor re-plans the freshly-minted (Ready, unclaimed) ball — the
//! new→existing transition *is* the convergence, not a special case.
//!
//! The effectful half — the mint, the piped `bl`/`lernie` executors, the claim
//! cross-check, and the detached `lernie prompt` — lives in [`exec`]; the goal
//! composition and the pre-mint preview in [`goal`]. Identity is **harness
//! stamped at fire**, never editable prefill (§3.3): [`goal::preview`] renders
//! the greyed `You are <name>.` prediction pre-submit, and [`exec::execute_prompt`]
//! prepends the phase-1 [`identity_preamble`] as it fires.

use crate::projects::join::JoinState;
use std::path::PathBuf;

mod exec;
mod goal;
mod run;
#[cfg(test)]
mod tests;

pub use exec::{
    ClaimResolved, DETACHED_EXIT, Deps, Prepared, StartError, cross_check_claim, execute_claim,
    execute_create, execute_ensure_workspace, execute_prompt, on_mint, resolve_name,
};
pub use goal::{Composer, identity_preamble, identity_preview, parse_ball_stamp, preview};
pub use run::{prepare, resolve_worktree};

/// The **where** axis (§3.4): the target workspace a prompt lands in, resolved to
/// a `(name, path)` in [`prepare`] before [`plan`]. Minting is the empty case of
/// the general path (bootstrap or the explicit + New verb), not a branch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
    /// Prompt into an existing workspace at `workspace` — the focused one (named
    /// **or foreign**), or a resume ball's claimant workspace (§8.1). No mint; the
    /// name is the path leaf (§3.4: prompting into the focused workspace is
    /// unconditional — a foreign workspace is a real lernie workspace, not a mint
    /// trigger). Carrying the path, not just a name, is what lets a foreign focus
    /// (outside yog's flat names root) resolve to the right `lernie prompt <ws>`.
    Existing { workspace: PathBuf },
    /// Mint a fresh name (§3.1) from the injected RNG and the occupied set
    /// ([`StartInputs::claimants`] + the names-root readdir); its path is
    /// `<names-root>/<name>` (§3.1).
    Mint,
}

/// The ball a start targets (§3.4 ball rung): an **existing** ball (id + join
/// state known, from the roster) or a **new** ball whose id `bl create` mints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BallSpec {
    Existing {
        id: String,
        title: String,
        body: String,
        join: JoinState,
    },
    New {
        title: String,
        body: String,
    },
}

/// The **what** axis (§3.4): the payload rung, each the one below plus inputs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Payload {
    /// bare — the empty composer; driver cwd `~`.
    Bare,
    /// path — a work directory; target preamble; driver cwd the directory.
    Path { dir: PathBuf },
    /// ball — a ball in `project`; `bl claim`/`create`; driver cwd the worktree.
    Ball { project: PathBuf, ball: BallSpec },
}

/// The **unresolved** start request the shell hands [`prepare`] / [`preview`]:
/// the two axes plus the roots and the mint's occupied-set claimants. `home` is
/// the bare rung's driver cwd (`~`, resolved from the env at the shell boundary).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartInputs {
    pub target: Target,
    pub payload: Payload,
    pub home: PathBuf,
    pub yog_data_root: PathBuf,
    pub balls_state_root: PathBuf,
    /// Occupied-set claimants for a [`Target::Mint`] (§3.1): the names `bl list`
    /// shows across projects, so a fresh name collides with no live claim.
    pub claimants: Vec<String>,
}

/// The **resolved** plan input (§8.1) [`plan`] is a pure function of: the target
/// workspace `name` and its `workspace` path (both minted-or-existing) and the
/// payload, plus the roots the worktree/seed paths derive from. The mint already
/// happened (in [`prepare`]); everything here is disk-and-join state. `name` is
/// the workspace leaf — the `--as`/`YOG_NAME`/identity stamp — and `workspace` is
/// its resolved absolute path (`<names-root>/<name>` for a mint, the focused path
/// for an existing one), the single source both the planner and the composer read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanInputs {
    pub name: String,
    pub workspace: PathBuf,
    pub payload: Payload,
    pub home: PathBuf,
    pub yog_data_root: PathBuf,
    pub balls_state_root: PathBuf,
}

/// One step of the start flow (§8.1). The sequence is a projection; [`prepare`]
/// runs the mutating steps in order and **defers** [`Prompt`](Step::Prompt) to
/// the composer (fired later, edited, by [`execute_prompt`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
    /// `LERNIE_HOME=… lernie prime` — always planned; the executor skips a seeded
    /// home (§16.6 W3, the general path with the seed present). A marker step: the
    /// executor derives the world layout from `yog_data_root` (the single source),
    /// so the step carries no path of its own.
    EnsureSeeded,
    /// `mkdir -p` + `lernie new <workspace>` — always planned; the executor skips
    /// an existing dir (§8.1 convergence; bootstrap is this with an absent dir).
    EnsureWorkspace { workspace: PathBuf },
    /// `bl create <title> [--body B]` — the ball New rung; mints the id, after
    /// which the plan re-derives as an existing ball (§8.1).
    Create {
        project: PathBuf,
        title: String,
        body: String,
    },
    /// `bl claim <id> --as <name>` — the ball rung, unclaimed only; stamped with
    /// the target workspace name (§3.2). Dropped for a bound ball (resume).
    Claim {
        project: PathBuf,
        id: String,
        name: String,
    },
    /// `lernie prompt <workspace> <goal>` fired detached, `YOG_NAME=<name>`, cwd
    /// per the §3.4 rung. `goal` is the editable payload prefill (identity is
    /// stamped at fire, not carried here, §3.3).
    Prompt {
        name: String,
        workspace: PathBuf,
        cwd: PathBuf,
        goal: String,
    },
}

/// The pure planner (§8.1): the amended-order step sequence to reach a running
/// loop. Substrate first (seed, `lernie new`), then the ball rung's `bl`
/// mutations (create for a new ball — the id defers the rest to a re-plan; else
/// claim when unclaimed), then the deferred prompt. Re-run after any step and it
/// converges to the shorter remainder.
pub fn plan(inputs: &PlanInputs) -> Vec<Step> {
    let workspace = inputs.workspace.clone();
    let mut steps = vec![
        Step::EnsureSeeded,
        Step::EnsureWorkspace {
            workspace: workspace.clone(),
        },
    ];
    match &inputs.payload {
        Payload::Ball {
            project,
            ball: BallSpec::New { title, body },
        } => {
            // The id is unknown until `bl create` mints it: emit create alone and
            // re-plan the minted ball (the new→existing convergence, §8.1).
            steps.push(Step::Create {
                project: project.clone(),
                title: title.clone(),
                body: body.clone(),
            });
            return steps;
        }
        Payload::Ball {
            project,
            ball: BallSpec::Existing { id, join, .. },
        } if claim_needed(*join) => {
            steps.push(Step::Claim {
                project: project.clone(),
                id: id.clone(),
                name: inputs.name.clone(),
            });
        }
        _ => {}
    }
    // The planner is pure, so the Prompt step previews the composer with the
    // ball's *canonical* worktree formula (§3.3); the executor re-composes with
    // the claim's cross-checked worktree (canonical or `<id>-<claimant>`) once it
    // has run [`Step::Claim`] — the executor's return is authoritative.
    let worktree = goal::canonical_worktree(&inputs.payload, &inputs.balls_state_root);
    let prepared = goal::compose_prepared(inputs, worktree.as_deref());
    steps.push(Step::Prompt {
        name: prepared.name,
        workspace: prepared.workspace,
        cwd: prepared.cwd,
        goal: prepared.goal,
    });
    steps
}

/// Whether the start flow must claim (§3.5, §8.1): a ready, unclaimed ball. A
/// ball already bound to its workspace ([`JoinState::Bound`]) drops the claim —
/// resume, not a second mint; re-claiming would trip bl's benign double-claim.
pub(crate) fn claim_needed(join: JoinState) -> bool {
    matches!(join, JoinState::ReadyStartable)
}

/// Whether the roster offers a ▶ Start affordance for this join state (§3.5,
/// §11): a ready ball. A [`JoinState::Bound`] ball already has a running
/// workspace (re-prompt is the composer's job, §3.4).
pub fn is_start_eligible(state: JoinState) -> bool {
    matches!(state, JoinState::ReadyStartable)
}

/// Whether the roster offers a ▶ Continue (resume) affordance for this join state
/// (§8.1 resume, addendum): a [`JoinState::Bound`] ball. [`plan`] is total over
/// every join state — it will happily plan for a claimed-elsewhere ball — so this
/// predicate is the **only** guard against resuming a ball this yog does not own;
/// it must stay covered, never a shell-glue check. Routes through the same planner
/// (prompt-only, since [`claim_needed`] is false for a bound ball).
pub fn is_resume_eligible(state: JoinState) -> bool {
    matches!(state, JoinState::Bound)
}