The file C:\Users\decid\Documents\projects\spt-core\crates\spt\Cargo.toml has been updated successfully. (file state is current in your context — no need to Read it back)

# `spt rc` raw terminal mode for the attach pump (M12-W1): enter/leave raw mode
# so keystrokes pass through to the broker-held PTY unbuffered + unechoed. The
# same crate W2's ratatui picker builds on, so it lands once for the milestone.
crossterm = "0.28"
# `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER). ratatui's
# double-buffered renderer removes the manual erase/redraw + wrapped-row-counting
# hazard; it renders to a testable `Buffer` (buffer + state-model unit asserts,
# no live terminal). 0.29 pairs with crossterm 0.28. nucleo-matcher = the `/`
# type-to-filter fuzzy match (the searchMultiselect feel, lifted from
# vercel-labs/skills).
ratatui = "0.29"
nucleo-matcher = "0.3"

[package]
name = "spt"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The `api digest-entry` push door validates the inbound record against the
# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.
spt-term = { path = "../spt-term" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }
# `spt rc` raw terminal mode for the attach pump (M12-W1): enter/leave raw mode
# so keystrokes pass through to the broker-held PTY unbuffered + unechoed. The
# same crate W2's ratatui picker builds on, so it lands once for the milestone.
crossterm = "0.28"
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }
# Terminal QR render for `spt subnet create` (M7 plan decision 11): the
# otpauth:// URI as unicode half-blocks. default-features off drops the
# image/svg renderers — the unicode renderer is all the CLI needs.
qrcode = { version = "0.14", default-features = false }
# Local-timezone rendering of the pairing code's expiry instant (std has no
# local tz). Clock feature only — no serde, no wasm.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# `spt update fetch` (REQ-UPD-7): HTTPS GET of the signed release from the
# GitHub origin. Blocking client + rustls (no OpenSSL); already in the tree
# transitively (iroh), so no new vendor. The fetch runs in the CLI, NOT the
# daemon — the daemon keeps zero HTTP surface (iroh QUIC only).
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"
# The D7-1 process-level survival int-test (brain_survive.rs) drives the
# production supervisor + an in-process broker/NetHost while spawning the REAL
# `spt daemon brain` child (CARGO_BIN_EXE_spt). Integration tests don't inherit
# the lib's normal deps, so the daemon/net/identity surfaces it touches directly
# are re-declared here (the spt-daemon dev-dep pattern).
spt-daemon = { path = "../spt-daemon" }
spt-net = { path = "../spt-net" }
spt-proto = { path = "../spt-proto" }

+# `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER). ratatui's

+# double-buffered renderer removes the manual erase/redraw + wrapped-row-counting

+# hazard; it renders to a testable `Buffer` (buffer + state-model unit asserts,

+# no live terminal). 0.29 pairs with crossterm 0.28. nucleo-matcher = the `/`

+# type-to-filter fuzzy match (the searchMultiselect feel, lifted from


Make Run{} adapter/id optional + add --create.


Edit

    Run {
        /// The harness adapter to host: `<adapter>[:profile]` (must be a
        /// registered `kind="harness"` adapter on this node).
        #[arg(long)]
        adapter: String,
        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`).
        #[arg(long)]
        id: String,
        /// Resume a prior session id instead of minting a fresh one.
        #[arg(long)]
        resume: Option<String>,
        /// Start the endpoint and return immediately (no attach).
        #[arg(long, conflicts_with_all = ["attach", "view"])]
        start: bool,
        /// Attach a local terminal after bringup (the default action).
        #[arg(long, conflicts_with_all = ["start", "view"])]
        attach: bool,
        /// Attach read-only after bringup (watch; forward no input).
        #[arg(long, conflicts_with_all = ["start", "attach"])]
        view: bool,
    },

    Run {
        /// The harness adapter to host: `<adapter>[:profile]` (must be a
        /// registered `kind="harness"` adapter on this node). Omit (with --id)
        /// to launch the interactive picker.
        #[arg(long)]
        adapter: Option<String>,
        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`). Omit
        /// (with --adapter) to launch the interactive picker.
        #[arg(long)]
        id: Option<String>,
        /// Mint a fresh session (the default; explicit so a non-interactive
        /// shortcut can bake create-vs-resume). Conflicts with --resume.
        #[arg(long, conflicts_with = "resume")]
        create: bool,
        /// Resume a prior session id instead of minting a fresh one.
        #[arg(long)]
        resume: Option<String>,
        /// Start the endpoint and return immediately (no attach).
        #[arg(long, conflicts_with_all = ["attach", "view"])]
        start: bool,
        /// Attach a local terminal after bringup (the default action).
        #[arg(long, conflicts_with_all = ["start", "view"])]
        attach: bool,
        /// Attach read-only after bringup (watch; forward no input).
        #[arg(long, conflicts_with_all = ["start", "attach"])]
        view: bool,
    },


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\cli.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    Run {
        /// The harness adapter to host: `<adapter>[:profile]` (must be a
        /// registered `kind="harness"` adapter on this node).
        #[arg(long)]
        adapter: String,
        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`).
        #[arg(long)]
        id: String,
        /// Resume a prior session id instead of minting a fresh one.
        #[arg(long)]
        resume: Option<String>,
        /// Start the endpoint and return immediately (no attach).
        #[arg(long, conflicts_with_all = ["attach", "view"])]
        start: bool,
        /// Attach a local terminal after bringup (the default action).
        #[arg(long, conflicts_with_all = ["start", "view"])]
        attach: bool,
        /// Attach read-only after bringup (watch; forward no input).
        #[arg(long, conflicts_with_all = ["start", "attach"])]
        view: bool,
    },

    Run {
        /// The harness adapter to host: `<adapter>[:profile]` (must be a
        /// registered `kind="harness"` adapter on this node). Omit (with --id)
        /// to launch the interactive picker.
        #[arg(long)]
        adapter: Option<String>,
        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`). Omit
        /// (with --adapter) to launch the interactive picker.
        #[arg(long)]
        id: Option<String>,
        /// Mint a fresh session (the default; explicit so a non-interactive
        /// shortcut can bake create-vs-resume). Conflicts with --resume.
        #[arg(long, conflicts_with = "resume")]
        create: bool,
        /// Resume a prior session id instead of minting a fresh one.
        #[arg(long)]
        resume: Option<String>,
        /// Start the endpoint and return immediately (no attach).
        #[arg(long, conflicts_with_all = ["attach", "view"])]
        start: bool,
        /// Attach a local terminal after bringup (the default action).
        #[arg(long, conflicts_with_all = ["start", "view"])]
        attach: bool,
        /// Attach read-only after bringup (watch; forward no input).
        #[arg(long, conflicts_with_all = ["start", "attach"])]
        view: bool,
    },

/// action so a `cc-<id>` shortcut can bake a fully non-interactive launch.

-        /// registered `kind="harness"` adapter on this node).

+        /// registered `kind="harness"` adapter on this node). Omit (with --id)

+        /// to launch the interactive picker.

-        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`).

+        /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`). Omit

+        /// (with --adapter) to launch the interactive picker.

+        /// Mint a fresh session (the default; explicit so a non-interactive

+        /// shortcut can bake create-vs-resume). Conflicts with --resume.

+        #[arg(long, conflicts_with = "resume")]


Now the dispatch — branch bare→picker, flags→non-interactive:


            EndpointCmd::Run {
                adapter,
                id,
                resume,
                start,
                attach,
                view,
            } => cmd_endpoint_run(&adapter, &id, resume, start, attach, view),

            EndpointCmd::Run {
                adapter,
                id,
                create,
                resume,
                start,
                attach,
                view,
            } => match (adapter, id) {
                // Both supplied → the REQ-HOST-RUN-1 non-interactive path
                // (untouched). `--create` is the default-fresh made explicit; it
                // only matters as the absence of `--resume`, so it is not
                // threaded further. The interactive picker bakes the same path.
                (Some(adapter), Some(id)) => {
                    cmd_endpoint_run(&adapter, &id, resume, start, attach, view)
                }
                // Bare (or partial) → the interactive picker (REQ-RUN-PICKER).
                // A lone --id pre-fills the create-new id; a lone --adapter
                // pre-selects the adapter.
                (adapter, id) => crate::picker::run(adapter, id),
            },


            EndpointCmd::Run {
                adapter,
                id,
                resume,
                start,
                attach,
                view,
            } => cmd_endpoint_run(&adapter, &id, resume, start, attach, view),

            EndpointCmd::Run {
                adapter,
                id,
                create,
                resume,
                start,
                attach,
                view,
            } => match (adapter, id) {
                // Both supplied → the REQ-HOST-RUN-1 non-interactive path
                // (untouched). `--create` is the default-fresh made explicit; it
                // only matters as the absence of `--resume`, so it is not
                // threaded further. The interactive picker bakes the same path.
                (Some(adapter), Some(id)) => {
                    cmd_endpoint_run(&adapter, &id, resume, start, attach, view)
                }
                // Bare (or partial) → the interactive picker (REQ-RUN-PICKER).
                // A lone --id pre-fills the create-new id; a lone --adapter
                // pre-selects the adapter.
                (adapter, id) => crate::picker::run(adapter, id),
            },

-            } => cmd_endpoint_run(&adapter, &id, resume, start, attach, view),

+                // Both supplied → the REQ-HOST-RUN-1 non-interactive path

+                // (untouched). `--create` is the default-fresh made explicit; it

+                // only matters as the absence of `--resume`, so it is not

+                // threaded further. The interactive picker bakes the same path.

+                (Some(adapter), Some(id)) => {

+                    cmd_endpoint_run(&adapter, &id, resume, start, attach, view)

+                // Bare (or partial) → the interactive picker (REQ-RUN-PICKER).

+                // A lone --id pre-fills the create-new id; a lone --adapter

+                // pre-selects the adapter.

+                (adapter, id) => crate::picker::run(adapter, id),

Cmd::Rc { id, view } => match crate::rc::run_attach(&id, view) {


Update M12-W2-PLAN.md with crystallized post-ruling design

Append a "Crystallized design (post-ruling)" section reflecting doyle's M12-W2-RULING.md: Q1 defer, Q2 best-effort, Q3 BUILD resume via sessions::last_k, Q4 sentinel-guarded per-OS shortcut, Q5 additive --create, 2 REQs doc+impl+unit no-int, single-bringup-path invariant.

Add REQ-RUN-PICKER + REQ-RUN-SHORTCUT to traceable-reqs.toml

Add both REQ-* entries (rule 3, before building). Stages doc+impl+unit, NO int (TUI manual-verify leg note per REQ-PAIR-6).

Add ratatui + nucleo-matcher deps; make Run{} adapter/id optional + add --create

spt/Cargo.toml: ratatui 0.29 (pairs crossterm 0.28) + nucleo-matcher. cli.rs Run{}: adapter/id → Option<String>, add --create flag; dispatch bare (no adapter/id) → picker, flags-present → cmd_endpoint_run untouched.

Build picker state model (pure, testable)

spt/src/picker/model.rs: screens (Kind/CreateNew/PickExisting/Confirm), nav transitions, action enum (single source of truth), status→square, adapter→profile tree, ledger→resume titles, filter narrowing, cursor windowing. Heavy unit tests.

Build picker data gather over existing surfaces

spt/src/picker/data.rs: harness adapters+profiles (registered/local_profile_names), endpoints by category project/local/subnet (roster + wansend snapshots + resource_projection), description (adapter:profile, project history best-effort contextstore, endpoint description), sessions::last_k resume rows. No new daemon surface.

Build picker ratatui view (Buffer-testable)

spt/src/picker/view.rs: render fns &Model→Buffer; kind screen, create-new adapter/profile tree + id entry, pick-existing category L/R + grouped list + status squares + two-pane description, confirm layer + options, pinned legend. Glyph set lifted. Buffer asserts via ratatui TestBackend.

Build cc-<id> shortcut generation (REQ-RUN-SHORTCUT)

spt/src/picker/shortcut.rs: per-OS native (.ps1 Win / sh+chmod Unix), bake terminal-action flags (adapter[:profile]+id+create|resume+start|attach|view), sentinel-guarded overwrite (generated-by header marker; overwrite own freely, refuse+warn if no sentinel). Unit-tested content + create-vs-update.

Wire picker live loop + terminal-action dispatch (single bringup path)

spt/src/picker/mod.rs: crossterm raw + ratatui Terminal live loop (manual-verify leg); every terminal action routes through cmd_endpoint_run / cmd_fork / rc pump — NO second bringup path (the gate invariant).

Tag evidence, full suite + clippy -D + traceable check, ping doyle to gate

Tag [impl->]/[unit->] in same change; run full test suite, clippy -D warnings, traceable-reqs check EXIT=0. Then ping doyle to gate (reproduce traceable + read load-bearing tests + confirm single-bringup-path).


write the JIT plan for W2, verify it with doyle, execute on their greenlight


Need to register `mod picker`. Check main.rs module decls + mark tasks done.


10:mod api;
11:mod cli;
12:mod elevation;
13:mod rc;
14:mod roster;
15:mod wansend;
18:mod testutil;


ToolSearch

Question: select:TaskUpdate


Question: select:TaskUpdate


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


Updated task #1 status


Updated task #2 status


Updated task #3 status


1	//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
2	//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
3	//! logic lives in the crate (clean-room separation per the M1 plan).
4	//!
5	//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
6	//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
7	//! the `api` group (REQ-API-1), dispatched from [`api`].
8	// [impl->REQ-MSG-2]
9
10	mod api;
11	mod cli;
12	mod elevation;
13	mod rc;
14	mod roster;
15	mod wansend;
16
17	#[cfg(test)]
18	mod testutil;
19
20	fn main() {
21	    // The sudo de-elevation drop (KNOWN-HAZARDS 5.7) runs BEFORE anything
22	    // touches state: a sudo'd `spt` otherwise lives in root's universe
23	    // (sudo flips $HOME, so spt_home — state, node identity, the daemon
24	    // socket names — all resolve wrong) and roots whatever it writes. Drop
25	    // to the ELECTED default user (uid/gid/HOME) while keeping the elevation
26	    // PROOF for the gated commands (`subnet create`/`join`, show-code):
27	    // the user paid for the gate by sudo'ing; the drop is state safety,
28	    // not authz revocation. Genuine root (no SUDO_*) is left alone — root's
29	    // universe IS the invoking user's universe then.
30	    //
31	    // This is the ONE site that runs while still root, so it MUST own the
32	    // account ELECTION (M8 decision 8, REQ-INSTALL-6): `daemon_target_user`
33	    // — not the bare `sudo_invoker` — so the first elevated `spt` records
34	    // `/etc/spt-core/default-user` and every later one drops deterministically
35	    // to that account. (The Daemon::run / spawn_detached election guards run
36	    // AFTER this drop — they always see euid != 0 and no-op; electing only
37	    // there left the record unwritten for a direct `sudo spt`. Field-found at
38	    // M8 acceptance: state landed under the invoker but the election never
39	    // persisted because this early drop shadowed both daemon-side guards.)
40	    // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod elevation;
mod rc;
mod roster;
mod wansend;

#[cfg(test)]
mod testutil;

fn main() {
    // The sudo de-elevation drop (KNOWN-HAZARDS 5.7) runs BEFORE anything
    // touches state: a sudo'd `spt` otherwise lives in root's universe
    // (sudo flips $HOME, so spt_home — state, node identity, the daemon
    // socket names — all resolve wrong) and roots whatever it writes. Drop
    // to the ELECTED default user (uid/gid/HOME) while keeping the elevation
    // PROOF for the gated commands (`subnet create`/`join`, show-code):
    // the user paid for the gate by sudo'ing; the drop is state safety,
    // not authz revocation. Genuine root (no SUDO_*) is left alone — root's
    // universe IS the invoking user's universe then.
    //
    // This is the ONE site that runs while still root, so it MUST own the
    // account ELECTION (M8 decision 8, REQ-INSTALL-6): `daemon_target_user`
    // — not the bare `sudo_invoker` — so the first elevated `spt` records
    // `/etc/spt-core/default-user` and every later one drops deterministically
    // to that account. (The Daemon::run / spawn_detached election guards run
    // AFTER this drop — they always see euid != 0 and no-op; electing only
    // there left the record unwritten for a direct `sudo spt`. Field-found at
    // M8 acceptance: state landed under the invoker but the election never
    // persisted because this early drop shadowed both daemon-side guards.)
    // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]


Edit

mod api;
mod cli;
mod elevation;
mod rc;

mod api;
mod cli;
mod elevation;
mod picker;
mod rc;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\main.rs has been updated successfully. (file state is current in your context — no need to Read it back)

mod api;
mod cli;
mod elevation;
mod picker;
mod rc;

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod elevation;
mod rc;
mod roster;
mod wansend;

#[cfg(test)]
mod testutil;

fn main() {
    // The sudo de-elevation drop (KNOWN-HAZARDS 5.7) runs BEFORE anything
    // touches state: a sudo'd `spt` otherwise lives in root's universe
    // (sudo flips $HOME, so spt_home — state, node identity, the daemon
    // socket names — all resolve wrong) and roots whatever it writes. Drop
    // to the ELECTED default user (uid/gid/HOME) while keeping the elevation
    // PROOF for the gated commands (`subnet create`/`join`, show-code):
    // the user paid for the gate by sudo'ing; the drop is state safety,
    // not authz revocation. Genuine root (no SUDO_*) is left alone — root's
    // universe IS the invoking user's universe then.
    //
    // This is the ONE site that runs while still root, so it MUST own the
    // account ELECTION (M8 decision 8, REQ-INSTALL-6): `daemon_target_user`
    // — not the bare `sudo_invoker` — so the first elevated `spt` records
    // `/etc/spt-core/default-user` and every later one drops deterministically
    // to that account. (The Daemon::run / spawn_detached election guards run
    // AFTER this drop — they always see euid != 0 and no-op; electing only
    // there left the record unwritten for a direct `sudo spt`. Field-found at
    // M8 acceptance: state landed under the invoker but the election never
    // persisted because this early drop shadowed both daemon-side guards.)
    // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
    // [impl->REQ-INSTALL-6]
    #[cfg(unix)]
    if let Some(invoker) = spt_daemon::deelevate::daemon_target_user() {
        match invoker.drop_in_process() {
            Ok(()) => {
                // Keep the elevation PROOF for the gated commands, but stay
                // silent on the routine drop (the one-time election itself
                // announces via `ELECTED_DEFAULT_USER`): a "DEELEVATED:
                // running as uid N" line only confuses an end user who just
                // wanted to create/join a subnet.
                elevation::mark_proven();
            }
            // A drop target existed but the drop failed: continuing would
            // run root over the user's universe — a known-torn state.
            // ABORT (user-ratified 2026-06-06: DEELEVATE_FAIL means spt
            // won't function properly; refuse, don't limp).
            Err(e) => {
                eprintln!("DEELEVATE_FAIL: {e} — refusing to run elevated over a user universe");
                std::process::exit(1);
            }
        }
    }
    let code = cli::run();
    std::process::exit(code);
}

mod api;
mod cli;
mod elevation;
+mod picker;
mod rc;
mod roster;
mod wansend;


mod registered. Now build the picker. Start with `model.rs` (pure, testable core).


Write

//! The picker **state model** — pure data + transitions, no terminal I/O.
//!
//! Everything testable about the picker lives here: the screen graph, cursor
//! windowing, category navigation, type-to-filter narrowing, the status→square
//! mapping, the adapter→profile tree build, the ledger→resume-title render, and
//! the confirm-layer option set. The live key loop ([`super::run`]) is a thin
//! driver over these transitions; `view.rs` renders a `&PickerModel` to a
//! ratatui `Buffer`. Keeping this layer pure is what lets a future tap-mode
//! (phone PTY) reuse the exact same model without re-coupling to keybinds
//! (the phone caveat).
// [impl->REQ-RUN-PICKER]

use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};

/// The recognizable glyph set, lifted from `vercel-labs/skills`
/// (`src/prompts/search-multiselect.ts`) for look/feel parity.
pub mod glyph {
    /// Online endpoint — filled square (rendered green in the view).
    pub const ONLINE: &str = "■";
    /// Offline endpoint — hollow square (rendered gray in the view).
    pub const OFFLINE: &str = "▢";
    /// The cursor caret.
    pub const CURSOR: &str = "❯";
    /// A tree branch into a nested profile.
    pub const BRANCH: &str = "└─";
}

/// Online / offline — the W2 status duality. The blue "attached" tri-state is
/// DEFERRED (doyle ruling Q1: it needs a broker attach-presence query + a
/// force-detach op, its own slice). Derived from the registry `Status` in
/// `data.rs`; the model only ever sees this reduced form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpStatus {
    Online,
    Offline,
}

impl EpStatus {
    /// The status square glyph (color is applied in the view).
    pub fn square(self) -> &'static str {
        match self {
            EpStatus::Online => glyph::ONLINE,
            EpStatus::Offline => glyph::OFFLINE,
        }
    }
}

/// One selectable adapter option in the create-new tree: a registered
/// `kind="harness"` adapter, optionally narrowed to one of its profiles. A
/// `None` profile = the bare adapter (its default view). The composite
/// `<adapter>[:profile]` address is what `cmd_endpoint_run` resolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterOption {
    pub adapter: String,
    pub profile: Option<String>,
    /// `true` for a profile leaf, `false` for the bare-adapter root row — drives
    /// the tree indentation in the view.
    pub is_leaf: bool,
}

impl AdapterOption {
    /// The composite `<adapter>` or `<adapter>:<profile>` address.
    pub fn address(&self) -> String {
        match &self.profile {
            Some(p) => format!("{}:{}", self.adapter, p),
            None => self.adapter.clone(),
        }
    }
}

/// The shipped+local profiles known for one adapter (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterInfo {
    pub name: String,
    /// Shipped profile names (from the parent manifest), sorted.
    pub shipped: Vec<String>,
    /// Local overlay profile names (beside the adapter), sorted.
    pub local: Vec<String>,
}

/// Flatten the gathered adapters into the tree-nested selectable row list: for
/// each adapter, a bare-adapter root row followed by one leaf row per profile
/// (shipped then local), each addressable as `<adapter>:<profile>`.
// [impl->REQ-RUN-PICKER]
pub fn build_adapter_tree(adapters: &[AdapterInfo]) -> Vec<AdapterOption> {
    let mut rows = Vec::new();
    for a in adapters {
        rows.push(AdapterOption {
            adapter: a.name.clone(),
            profile: None,
            is_leaf: false,
        });
        for p in a.shipped.iter().chain(a.local.iter()) {
            rows.push(AdapterOption {
                adapter: a.name.clone(),
                profile: Some(p.clone()),
                is_leaf: true,
            });
        }
    }
    rows
}

/// One prior session from the per-endpoint ledger, rendered as a Resume option.
/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeRow {
    pub session_id: String,
    pub ts: String,
    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
    /// no project association exists (the ledger row carries no project).
    pub trigger: String,
    /// Best-effort project name (empty if none derivable).
    pub project: String,
}

impl ResumeRow {
    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
    pub fn title(&self) -> String {
        let head = if self.project.is_empty() {
            &self.trigger
        } else {
            &self.project
        };
        let id5: String = {
            let s = &self.session_id;
            let n = s.chars().count();
            s.chars().skip(n.saturating_sub(5)).collect()
        };
        format!("{head} @ {} (…{id5})", self.ts)
    }
}

/// A pick-existing category. Left/right cycles through these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
    /// Endpoints scoped to the cwd project.
    Project,
    /// This node's local perches.
    Local,
    /// Subnet endpoints.
    Subnet,
}

impl Category {
    /// Display label (the project category carries the cwd project name).
    pub fn label(self, project_name: &str) -> String {
        match self {
            Category::Project => format!("{project_name} project"),
            Category::Local => "Local node".to_string(),
            Category::Subnet => "Subnet".to_string(),
        }
    }

    /// The ordered category ring for left/right navigation.
    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
}

/// One discovered endpoint row (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRow {
    pub id: String,
    /// Grouping label within the category (e.g. "local", "subnet:node",
    /// project name) — rows render grouped then alpha-sorted.
    pub group: String,
    pub node: String,
    pub status: EpStatus,
    /// `true` if this endpoint lives on THIS node (gates Resume-from-history,
    /// which is offline+LOCAL only).
    pub is_local: bool,
    /// The right-pane description: harness `adapter:profile`.
    pub adapter_profile: String,
    /// Project history newest→oldest (best-effort; empty if none).
    pub project_history: Vec<String>,
    /// The `spt endpoint description` blurb (empty if none).
    pub description: String,
    /// Prior sessions for Resume-from-history (only populated for offline+local).
    pub resume_rows: Vec<ResumeRow>,
}

/// Which screen the picker is on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    /// Layer 1 — "Create new | Pick existing".
    Kind,
    /// Create-new L2 — pick an adapter:profile from the tree.
    CreateAdapter,
    /// Create-new L3 — type the endpoint id.
    CreateId,
    /// Pick-existing L2 — category + grouped endpoint list + description pane.
    PickExisting,
    /// Pick-existing L3 — confirm + status-dependent options.
    Confirm,
}

/// Layer-1 kind choices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    CreateNew,
    PickExisting,
}

/// A confirm-layer option. Resume/Fork/Instantiate/ChangeAdapter availability is
/// status-dependent (see [`PickerModel::confirm_options`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmOption {
    Attach,
    Start,
    View,
    /// Offline + LOCAL only — enumerate the session ledger.
    Resume,
    /// Offline only — re-enter create-new to change the harness adapter.
    ChangeAdapter,
    /// Remote only — re-enter create-new to bring a copy up here.
    Instantiate,
    /// Always available — cross-subnet clone to a new id.
    Fork,
    /// The `s` keybind — build/update the `cc-<id>` shortcut.
    Shortcut,
}

/// The terminal outcome of the picker — the single action enum every screen
/// converges on. `run()` dispatches each variant through the EXISTING bringup
/// fns (`cmd_endpoint_run` / `cmd_fork` / the rc pump) — no second bringup path
/// (the gate invariant).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// Bring up (or resume) an endpoint then start/attach/view.
    Run {
        adapter: String,
        id: String,
        resume: Option<String>,
        mode: RunMode,
    },
    /// Write/update a `cc-<id>` shortcut, then stay (re-enter the loop).
    Shortcut {
        adapter: String,
        id: String,
        resume: Option<String>,
        mode: RunMode,
    },
    /// User backed all the way out.
    Cancelled,
}

/// The terminal action a Run baked: start (return), attach (default), or view
/// (read-only). Maps 1:1 onto `cmd_endpoint_run`'s start/attach/view flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
    Start,
    Attach,
    View,
}

/// The whole picker state. One struct, mutated by the transition methods; the
/// view renders it; `run()` drives keys into it.
pub struct PickerModel {
    pub screen: Screen,
    /// The cwd project name (the Project category label).
    pub project_name: String,

    // Layer 1.
    pub kind_cursor: usize,

    // Create-new.
    pub adapter_rows: Vec<AdapterOption>,
    pub adapter_cursor: usize,
    pub id_buffer: String,
    /// Set when the id buffer fails the charset check (shown inline).
    pub id_error: Option<String>,

    // Pick-existing.
    pub category: Category,
    /// All gathered endpoints (every category); the view filters by category.
    pub endpoints: Vec<EndpointRow>,
    pub item_cursor: usize,
    /// The `/` type-to-filter query.
    pub query: String,
    pub filtering: bool,

    // Confirm.
    pub confirm_cursor: usize,
}

impl PickerModel {
    /// A fresh model on the kind screen. `prefill_adapter`/`prefill_id` come from
    /// a lone `--adapter`/`--id` on a bare-ish `spt endpoint run`.
    pub fn new(
        project_name: String,
        adapter_rows: Vec<AdapterOption>,
        endpoints: Vec<EndpointRow>,
        prefill_adapter: Option<String>,
        prefill_id: Option<String>,
    ) -> Self {
        // A lone --adapter pre-selects its row; a lone --id pre-fills the id and
        // jumps straight into create-new.
        let adapter_cursor = prefill_adapter
            .as_deref()
            .and_then(|a| adapter_rows.iter().position(|r| r.address() == a || r.adapter == a))
            .unwrap_or(0);
        let screen = if prefill_id.is_some() || prefill_adapter.is_some() {
            Screen::CreateAdapter
        } else {
            Screen::Kind
        };
        Self {
            screen,
            project_name,
            kind_cursor: 0,
            adapter_rows,
            adapter_cursor,
            id_buffer: prefill_id.unwrap_or_default(),
            id_error: None,
            category: Category::Project,
            endpoints,
            item_cursor: 0,
            query: String::new(),
            filtering: false,
            confirm_cursor: 0,
        }
    }

    // ── Kind layer ────────────────────────────────────────────────────────
    pub fn kind_at_cursor(&self) -> Kind {
        if self.kind_cursor == 0 {
            Kind::CreateNew
        } else {
            Kind::PickExisting
        }
    }

    /// Enter from the kind layer into the chosen branch.
    pub fn enter_kind(&mut self) {
        match self.kind_at_cursor() {
            Kind::CreateNew => self.screen = Screen::CreateAdapter,
            Kind::PickExisting => {
                self.screen = Screen::PickExisting;
                self.item_cursor = 0;
            }
        }
    }

    // ── Generic cursor windowing ─────────────────────────────────────────
    /// Move a cursor within `[0, len)`, clamping at the ends (no wrap — matches
    /// the searchMultiselect windowed cursor; wrap would disorient the two-pane
    /// description sync).
    fn step(cursor: usize, len: usize, delta: isize) -> usize {
        if len == 0 {
            return 0;
        }
        let max = len - 1;
        let next = cursor as isize + delta;
        next.clamp(0, max as isize) as usize
    }

    // ── Create-new: adapter tree ─────────────────────────────────────────
    pub fn move_adapter(&mut self, delta: isize) {
        self.adapter_cursor = Self::step(self.adapter_cursor, self.adapter_rows.len(), delta);
    }

    pub fn selected_adapter(&self) -> Option<&AdapterOption> {
        self.adapter_rows.get(self.adapter_cursor)
    }

    /// Advance from adapter selection into id entry.
    pub fn enter_adapter(&mut self) {
        if self.selected_adapter().is_some() {
            self.screen = Screen::CreateId;
        }
    }

    // ── Create-new: id entry ─────────────────────────────────────────────
    /// Append a typed char to the id, re-validating the charset live.
    pub fn id_push(&mut self, c: char) {
        self.id_buffer.push(c);
        self.revalidate_id();
    }

    pub fn id_backspace(&mut self) {
        self.id_buffer.pop();
        self.revalidate_id();
    }

    fn revalidate_id(&mut self) {
        self.id_error = match spt_proto::id::validate_endpoint_id(&self.id_buffer) {
            Ok(()) => None,
            Err(e) => Some(e.to_string()),
        };
    }

    /// The create-new outcome (bare adapter address + typed id, fresh session,
    /// attach). `None` if the id is empty/invalid or no adapter is selected.
    pub fn create_outcome(&self) -> Option<Outcome> {
        if self.id_buffer.is_empty() || self.id_error.is_some() {
            return None;
        }
        let adapter = self.selected_adapter()?.address();
        Some(Outcome::Run {
            adapter,
            id: self.id_buffer.clone(),
            resume: None,
            mode: RunMode::Attach,
        })
    }

    // ── Pick-existing: category + list ───────────────────────────────────
    /// Cycle the category ring left (-1) or right (+1); resets the item cursor.
    pub fn move_category(&mut self, delta: isize) {
        let cur = Category::ALL.iter().position(|c| *c == self.category).unwrap_or(0);
        let len = Category::ALL.len() as isize;
        let next = ((cur as isize + delta) % len + len) % len; // category wraps
        self.category = Category::ALL[next as usize];
        self.item_cursor = 0;
    }

    /// Indices into `endpoints` visible in the current category, after the
    /// type-to-filter, ordered grouped-then-alpha (group asc, then id asc) with
    /// the fuzzy filter applied. The single source the view and the cursor share.
    // [impl->REQ-RUN-PICKER]
    pub fn visible_indices(&self) -> Vec<usize> {
        let want_local = self.category == Category::Local;
        // Project = local endpoints whose history names the cwd project; Local =
        // all local perches; Subnet = remote rows. (data.rs sets is_local +
        // group; the model just partitions.)
        let mut idx: Vec<usize> = self
            .endpoints
            .iter()
            .enumerate()
            .filter(|(_, e)| match self.category {
                Category::Local => e.is_local,
                Category::Subnet => !e.is_local,
                Category::Project => {
                    e.is_local && e.project_history.iter().any(|p| p == &self.project_name)
                }
            })
            .map(|(i, _)| i)
            .collect();
        let _ = want_local;
        // Grouped then alpha.
        idx.sort_by(|&a, &b| {
            let ea = &self.endpoints[a];
            let eb = &self.endpoints[b];
            ea.group.cmp(&eb.group).then(ea.id.cmp(&eb.id))
        });
        if self.query.is_empty() {
            return idx;
        }
        // Fuzzy-filter by id, preserving match-score order (nucleo).
        let mut matcher = Matcher::new(Config::DEFAULT);
        let pattern = Pattern::parse(&self.query, CaseMatching::Ignore, Normalization::Smart);
        let names: Vec<String> = idx.iter().map(|&i| self.endpoints[i].id.clone()).collect();
        let scored = pattern.match_list(names.iter().map(|s| s.as_str()), &mut matcher);
        // map matched names back to their endpoint indices, score order
        scored
            .into_iter()
            .filter_map(|(name, _)| {
                idx.iter().copied().find(|&i| self.endpoints[i].id == name)
            })
            .collect()
    }

    /// The endpoint under the item cursor (within the current visible set).
    pub fn selected_endpoint(&self) -> Option<&EndpointRow> {
        let vis = self.visible_indices();
        vis.get(self.item_cursor).map(|&i| &self.endpoints[i])
    }

    pub fn move_item(&mut self, delta: isize) {
        let len = self.visible_indices().len();
        self.item_cursor = Self::step(self.item_cursor, len, delta);
    }

    // ── Filter ───────────────────────────────────────────────────────────
    pub fn start_filter(&mut self) {
        self.filtering = true;
    }
    pub fn filter_push(&mut self, c: char) {
        self.query.push(c);
        self.item_cursor = 0;
    }
    pub fn filter_backspace(&mut self) {
        self.query.pop();
        self.item_cursor = 0;
    }
    pub fn end_filter(&mut self) {
        self.filtering = false;
    }

    /// Enter the confirm layer for the selected endpoint.
    pub fn enter_pick(&mut self) {
        if self.selected_endpoint().is_some() {
            self.screen = Screen::Confirm;
            self.confirm_cursor = 0;
        }
    }

    // ── Confirm layer ────────────────────────────────────────────────────
    /// The status-dependent option set for the selected endpoint (picker spec /
    /// doyle Q1/Q3). Order is stable so the cursor + the view agree.
    // [impl->REQ-RUN-PICKER]
    pub fn confirm_options(&self) -> Vec<ConfirmOption> {
        let Some(ep) = self.selected_endpoint() else {
            return Vec::new();
        };
        let mut opts = vec![ConfirmOption::Attach, ConfirmOption::Start, ConfirmOption::View];
        let offline = ep.status == EpStatus::Offline;
        // Resume-from-history: offline + LOCAL only, and only if the ledger has
        // rows (doyle Q3). A remote offline endpoint gets Instantiate instead.
        if offline && ep.is_local && !ep.resume_rows.is_empty() {
            opts.push(ConfirmOption::Resume);
        }
        // Change-harness-adapter: offline only.
        if offline {
            opts.push(ConfirmOption::ChangeAdapter);
        }
        // Instantiate-locally: a remote endpoint copied up here.
        if !ep.is_local {
            opts.push(ConfirmOption::Instantiate);
        }
        // Fork: always available.
        opts.push(ConfirmOption::Fork);
        // The `s` shortcut is always available (a keybind, listed last).
        opts.push(ConfirmOption::Shortcut);
        opts
    }

    pub fn move_confirm(&mut self, delta: isize) {
        let len = self.confirm_options().len();
        self.confirm_cursor = Self::step(self.confirm_cursor, len, delta);
    }

    pub fn selected_confirm(&self) -> Option<ConfirmOption> {
        self.confirm_options().get(self.confirm_cursor).copied()
    }

    /// Back out one screen; returns `true` if it backed out of the whole picker
    /// (the caller then yields `Outcome::Cancelled`).
    pub fn back(&mut self) -> bool {
        match self.screen {
            Screen::Kind => true,
            Screen::CreateAdapter => {
                self.screen = Screen::Kind;
                false
            }
            Screen::CreateId => {
                self.screen = Screen::CreateAdapter;
                false
            }
            Screen::PickExisting => {
                self.screen = Screen::Kind;
                false
            }
            Screen::Confirm => {
                self.screen = Screen::PickExisting;
                false
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn info(name: &str, shipped: &[&str], local: &[&str]) -> AdapterInfo {
        AdapterInfo {
            name: name.to_string(),
            shipped: shipped.iter().map(|s| s.to_string()).collect(),
            local: local.iter().map(|s| s.to_string()).collect(),
        }
    }

    fn ep(id: &str, group: &str, status: EpStatus, is_local: bool) -> EndpointRow {
        EndpointRow {
            id: id.to_string(),
            group: group.to_string(),
            node: "n".to_string(),
            status,
            is_local,
            adapter_profile: "claude-spt".to_string(),
            project_history: vec![],
            description: String::new(),
            resume_rows: vec![],
        }
    }

    // [unit->REQ-RUN-PICKER] the status→square mapping is the W2 duality (no blue).
    #[test]
    fn status_square_is_online_offline_only() {
        assert_eq!(EpStatus::Online.square(), glyph::ONLINE);
        assert_eq!(EpStatus::Offline.square(), glyph::OFFLINE);
    }

    // [unit->REQ-RUN-PICKER] the adapter tree nests profiles under each adapter,
    // bare-root first then shipped then local, each composite-addressable.
    #[test]
    fn adapter_tree_nests_profiles() {
        let rows = build_adapter_tree(&[
            info("claude-spt", &["fast"], &["mine"]),
            info("ccs", &[], &[]),
        ]);
        let addrs: Vec<String> = rows.iter().map(|r| r.address()).collect();
        assert_eq!(
            addrs,
            vec!["claude-spt", "claude-spt:fast", "claude-spt:mine", "ccs"]
        );
        assert!(!rows[0].is_leaf, "bare-adapter root is not a leaf");
        assert!(rows[1].is_leaf, "a profile is a leaf");
    }

    // [unit->REQ-RUN-PICKER] resume titles render `<project|trigger> @ ts (…id5)`.
    #[test]
    fn resume_title_render() {
        let with_proj = ResumeRow {
            session_id: "abcdef12345".to_string(),
            ts: "2026-06-14T10:00:00Z".to_string(),
            trigger: "clear".to_string(),
            project: "spt-core".to_string(),
        };
        assert_eq!(with_proj.title(), "spt-core @ 2026-06-14T10:00:00Z (…12345)");
        let no_proj = ResumeRow {
            session_id: "xy".to_string(),
            ts: "T".to_string(),
            trigger: "boot".to_string(),
            project: String::new(),
        };
        // short id (<5) takes the whole thing; no project falls back to trigger.
        assert_eq!(no_proj.title(), "boot @ T (…xy)");
    }

    // [unit->REQ-RUN-PICKER] kind layer routes into the right branch.
    #[test]
    fn kind_routes() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        assert_eq!(m.screen, Screen::Kind);
        m.kind_cursor = 0;
        m.enter_kind();
        assert_eq!(m.screen, Screen::CreateAdapter);
        m.back();
        m.kind_cursor = 1;
        m.enter_kind();
        assert_eq!(m.screen, Screen::PickExisting);
    }

    // [unit->REQ-RUN-PICKER] cursor windowing clamps (no wrap) at both ends.
    #[test]
    fn cursor_clamps() {
        assert_eq!(PickerModel::step(0, 3, -1), 0);
        assert_eq!(PickerModel::step(2, 3, 1), 2);
        assert_eq!(PickerModel::step(1, 3, 1), 2);
        assert_eq!(PickerModel::step(0, 0, 1), 0);
    }

    // [unit->REQ-RUN-PICKER] category nav wraps the ring + resets the item cursor.
    #[test]
    fn category_ring_wraps() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        assert_eq!(m.category, Category::Project);
        m.move_category(-1);
        assert_eq!(m.category, Category::Subnet, "left from first wraps to last");
        m.move_category(1);
        assert_eq!(m.category, Category::Project);
    }

    // [unit->REQ-RUN-PICKER] visible_indices partitions by category, grouped+alpha.
    #[test]
    fn visible_partitions_and_sorts() {
        let eps = vec![
            ep("zeta", "g1", EpStatus::Online, true),
            ep("alpha", "g1", EpStatus::Offline, true),
            ep("remote", "subnet:n", EpStatus::Online, false),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None);
        m.category = Category::Local;
        let vis = m.visible_indices();
        let ids: Vec<&str> = vis.iter().map(|&i| m.endpoints[i].id.as_str()).collect();
        assert_eq!(ids, vec!["alpha", "zeta"], "local, alpha within group");
        m.category = Category::Subnet;
        let ids: Vec<&str> = m
            .visible_indices()
            .iter()
            .map(|&i| m.endpoints[i].id.as_str())
            .collect();
        assert_eq!(ids, vec!["remote"]);
    }

    // [unit->REQ-RUN-PICKER] type-to-filter narrows the visible set.
    #[test]
    fn filter_narrows() {
        let eps = vec![
            ep("doyle", "g", EpStatus::Online, true),
            ep("todlando", "g", EpStatus::Online, true),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None);
        m.category = Category::Local;
        assert_eq!(m.visible_indices().len(), 2);
        m.start_filter();
        for c in "doy".chars() {
            m.filter_push(c);
        }
        let vis = m.visible_indices();
        assert_eq!(vis.len(), 1);
        assert_eq!(m.endpoints[vis[0]].id, "doyle");
    }

    // [unit->REQ-RUN-PICKER] confirm options are status-dependent: offline+local
    // with ledger rows gets Resume; online does not; remote gets Instantiate.
    #[test]
    fn confirm_options_status_dependent() {
        let mut offline_local = ep("a", "g", EpStatus::Offline, true);
        offline_local.resume_rows = vec![ResumeRow {
            session_id: "s".into(),
            ts: "t".into(),
            trigger: "boot".into(),
            project: String::new(),
        }];
        let mut m = PickerModel::new("p".into(), vec![], vec![offline_local], None, None);
        m.category = Category::Local;
        m.enter_pick();
        let opts = m.confirm_options();
        assert!(opts.contains(&ConfirmOption::Resume), "offline+local+ledger ⇒ Resume");
        assert!(opts.contains(&ConfirmOption::ChangeAdapter), "offline ⇒ ChangeAdapter");
        assert!(!opts.contains(&ConfirmOption::Instantiate), "local ⇒ no Instantiate");
        assert!(opts.contains(&ConfirmOption::Fork) && opts.contains(&ConfirmOption::Shortcut));

        // online local: no Resume, no ChangeAdapter.
        let online = ep("b", "g", EpStatus::Online, true);
        let mut m2 = PickerModel::new("p".into(), vec![], vec![online], None, None);
        m2.category = Category::Local;
        m2.enter_pick();
        let opts2 = m2.confirm_options();
        assert!(!opts2.contains(&ConfirmOption::Resume));
        assert!(!opts2.contains(&ConfirmOption::ChangeAdapter));

        // remote: Instantiate present.
        let remote = ep("c", "subnet:n", EpStatus::Online, false);
        let mut m3 = PickerModel::new("p".into(), vec![], vec![remote], None, None);
        m3.category = Category::Subnet;
        m3.enter_pick();
        assert!(m3.confirm_options().contains(&ConfirmOption::Instantiate));
    }

    // [unit->REQ-RUN-PICKER] create-new outcome bakes adapter+id, fresh, attach;
    // refuses an empty/invalid id.
    #[test]
    fn create_outcome_bakes_run() {
        let rows = build_adapter_tree(&[info("claude-spt", &["fast"], &[])]);
        let mut m = PickerModel::new("p".into(), rows, vec![], None, None);
        m.screen = Screen::CreateAdapter;
        m.adapter_cursor = 1; // claude-spt:fast
        m.enter_adapter();
        assert_eq!(m.screen, Screen::CreateId);
        assert!(m.create_outcome().is_none(), "empty id ⇒ no outcome");
        for c in "doyle".chars() {
            m.id_push(c);
        }
        match m.create_outcome().unwrap() {
            Outcome::Run { adapter, id, resume, mode } => {
                assert_eq!(adapter, "claude-spt:fast");
                assert_eq!(id, "doyle");
                assert_eq!(resume, None);
                assert_eq!(mode, RunMode::Attach);
            }
            other => panic!("expected Run, got {other:?}"),
        }
        // an invalid char sets the error and blocks the outcome.
        m.id_push(' ');
        assert!(m.id_error.is_some());
        assert!(m.create_outcome().is_none());
    }

    // [unit->REQ-RUN-PICKER] back navigation unwinds the screen graph, signalling
    // cancellation only from the kind layer.
    #[test]
    fn back_unwinds() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        m.screen = Screen::Confirm;
        assert!(!m.back());
        assert_eq!(m.screen, Screen::PickExisting);
        assert!(!m.back());
        assert_eq!(m.screen, Screen::Kind);
        assert!(m.back(), "back from kind cancels the picker");
    }

    // [unit->REQ-RUN-PICKER] a lone --id prefills and jumps into create-new.
    #[test]
    fn prefill_jumps_to_create() {
        let rows = build_adapter_tree(&[info("claude-spt", &[], &[])]);
        let m = PickerModel::new("p".into(), rows, vec![], None, Some("doyle".into()));
        assert_eq!(m.screen, Screen::CreateAdapter);
        assert_eq!(m.id_buffer, "doyle");
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt\src\picker\model.rs (file state is current in your context — no need to Read it back)

//! The picker **state model** — pure data + transitions, no terminal I/O.
//!
//! Everything testable about the picker lives here: the screen graph, cursor
//! windowing, category navigation, type-to-filter narrowing, the status→square
//! mapping, the adapter→profile tree build, the ledger→resume-title render, and
//! the confirm-layer option set. The live key loop ([`super::run`]) is a thin
//! driver over these transitions; `view.rs` renders a `&PickerModel` to a
//! ratatui `Buffer`. Keeping this layer pure is what lets a future tap-mode
//! (phone PTY) reuse the exact same model without re-coupling to keybinds
//! (the phone caveat).
// [impl->REQ-RUN-PICKER]

use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};

/// The recognizable glyph set, lifted from `vercel-labs/skills`
/// (`src/prompts/search-multiselect.ts`) for look/feel parity.
pub mod glyph {
    /// Online endpoint — filled square (rendered green in the view).
    pub const ONLINE: &str = "■";
    /// Offline endpoint — hollow square (rendered gray in the view).
    pub const OFFLINE: &str = "▢";
    /// The cursor caret.
    pub const CURSOR: &str = "❯";
    /// A tree branch into a nested profile.
    pub const BRANCH: &str = "└─";
}

/// Online / offline — the W2 status duality. The blue "attached" tri-state is
/// DEFERRED (doyle ruling Q1: it needs a broker attach-presence query + a
/// force-detach op, its own slice). Derived from the registry `Status` in
/// `data.rs`; the model only ever sees this reduced form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpStatus {
    Online,
    Offline,
}

impl EpStatus {
    /// The status square glyph (color is applied in the view).
    pub fn square(self) -> &'static str {
        match self {
            EpStatus::Online => glyph::ONLINE,
            EpStatus::Offline => glyph::OFFLINE,
        }
    }
}

/// One selectable adapter option in the create-new tree: a registered
/// `kind="harness"` adapter, optionally narrowed to one of its profiles. A
/// `None` profile = the bare adapter (its default view). The composite
/// `<adapter>[:profile]` address is what `cmd_endpoint_run` resolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterOption {
    pub adapter: String,
    pub profile: Option<String>,
    /// `true` for a profile leaf, `false` for the bare-adapter root row — drives
    /// the tree indentation in the view.
    pub is_leaf: bool,
}

impl AdapterOption {
    /// The composite `<adapter>` or `<adapter>:<profile>` address.
    pub fn address(&self) -> String {
        match &self.profile {
            Some(p) => format!("{}:{}", self.adapter, p),
            None => self.adapter.clone(),
        }
    }
}

/// The shipped+local profiles known for one adapter (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterInfo {
    pub name: String,
    /// Shipped profile names (from the parent manifest), sorted.
    pub shipped: Vec<String>,
    /// Local overlay profile names (beside the adapter), sorted.
    pub local: Vec<String>,
}

/// Flatten the gathered adapters into the tree-nested selectable row list: for
/// each adapter, a bare-adapter root row followed by one leaf row per profile
/// (shipped then local), each addressable as `<adapter>:<profile>`.
// [impl->REQ-RUN-PICKER]
pub fn build_adapter_tree(adapters: &[AdapterInfo]) -> Vec<AdapterOption> {
    let mut rows = Vec::new();
    for a in adapters {
        rows.push(AdapterOption {
            adapter: a.name.clone(),
            profile: None,
            is_leaf: false,
        });
        for p in a.shipped.iter().chain(a.local.iter()) {
            rows.push(AdapterOption {
                adapter: a.name.clone(),
                profile: Some(p.clone()),
                is_leaf: true,
            });
        }
    }
    rows
}

/// One prior session from the per-endpoint ledger, rendered as a Resume option.
/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeRow {
    pub session_id: String,
    pub ts: String,
    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
    /// no project association exists (the ledger row carries no project).
    pub trigger: String,
    /// Best-effort project name (empty if none derivable).
    pub project: String,
}

impl ResumeRow {
    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
    pub fn title(&self) -> String {
        let head = if self.project.is_empty() {
            &self.trigger
        } else {
            &self.project
        };
        let id5: String = {
            let s = &self.session_id;
            let n = s.chars().count();
            s.chars().skip(n.saturating_sub(5)).collect()
        };
        format!("{head} @ {} (…{id5})", self.ts)
    }
}

/// A pick-existing category. Left/right cycles through these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
    /// Endpoints scoped to the cwd project.
    Project,
    /// This node's local perches.
    Local,
    /// Subnet endpoints.
    Subnet,
}

impl Category {
    /// Display label (the project category carries the cwd project name).
    pub fn label(self, project_name: &str) -> String {
        match self {
            Category::Project => format!("{project_name} project"),
            Category::Local => "Local node".to_string(),
            Category::Subnet => "Subnet".to_string(),
        }
    }

    /// The ordered category ring for left/right navigation.
    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
}

/// One discovered endpoint row (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRow {
    pub id: String,
    /// Grouping label within the category (e.g. "local", "subnet:node",
    /// project name) — rows render grouped then alpha-sorted.
    pub group: String,
    pub node: String,
    pub status: EpStatus,
    /// `true` if this endpoint lives on THIS node (gates Resume-from-history,
    /// which is offline+LOCAL only).
    pub is_local: bool,
    /// The right-pane description: harness `adapter:profile`.
    pub adapter_profile: String,
    /// Project history newest→oldest (best-effort; empty if none).
    pub project_history: Vec<String>,
    /// The `spt endpoint description` blurb (empty if none).
    pub description: String,
    /// Prior sessions for Resume-from-history (only populated for offline+local).
    pub resume_rows: Vec<ResumeRow>,
}

/// Which screen the picker is on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    /// Layer 1 — "Create new | Pick existing".
    Kind,
    /// Create-new L2 — pick an adapter:profile from the tree.
    CreateAdapter,
    /// Create-new L3 — type the endpoint id.
    CreateId,
    /// Pick-existing L2 — category + grouped endpoint list + description pane.
    PickExisting,
    /// Pick-existing L3 — confirm + status-dependent options.
    Confirm,
}

/// Layer-1 kind choices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    CreateNew,
    PickExisting,
}

/// A confirm-layer option. Resume/Fork/Instantiate/ChangeAdapter availability is
/// status-dependent (see [`PickerModel::confirm_options`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmOption {
    Attach,
    Start,
    View,
    /// Offline + LOCAL only — enumerate the session ledger.
    Resume,
    /// Offline only — re-enter create-new to change the harness adapter.
    ChangeAdapter,
    /// Remote only — re-enter create-new to bring a copy up here.
    Instantiate,
    /// Always available — cross-subnet clone to a new id.
    Fork,
    /// The `s` keybind — build/update the `cc-<id>` shortcut.
    Shortcut,
}

/// The terminal outcome of the picker — the single action enum every screen
/// converges on. `run()` dispatches each variant through the EXISTING bringup
/// fns (`cmd_endpoint_run` / `cmd_fork` / the rc pump) — no second bringup path
/// (the gate invariant).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// Bring up (or resume) an endpoint then start/attach/view.
    Run {
        adapter: String,
        id: String,
        resume: Option<String>,
        mode: RunMode,
    },
    /// Write/update a `cc-<id>` shortcut, then stay (re-enter the loop).
    Shortcut {
        adapter: String,
        id: String,
        resume: Option<String>,
        mode: RunMode,
    },
    /// User backed all the way out.
    Cancelled,
}

/// The terminal action a Run baked: start (return), attach (default), or view
/// (read-only). Maps 1:1 onto `cmd_endpoint_run`'s start/attach/view flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
    Start,
    Attach,
    View,
}

/// The whole picker state. One struct, mutated by the transition methods; the
/// view renders it; `run()` drives keys into it.
pub struct PickerModel {
    pub screen: Screen,
    /// The cwd project name (the Project category label).
    pub project_name: String,

    // Layer 1.
    pub kind_cursor: usize,

    // Create-new.
    pub adapter_rows: Vec<AdapterOption>,
    pub adapter_cursor: usize,
    pub id_buffer: String,
    /// Set when the id buffer fails the charset check (shown inline).
    pub id_error: Option<String>,

    // Pick-existing.
    pub category: Category,
    /// All gathered endpoints (every category); the view filters by category.
    pub endpoints: Vec<EndpointRow>,
    pub item_cursor: usize,
    /// The `/` type-to-filter query.
    pub query: String,
    pub filtering: bool,

    // Confirm.
    pub confirm_cursor: usize,
}

impl PickerModel {
    /// A fresh model on the kind screen. `prefill_adapter`/`prefill_id` come from
    /// a lone `--adapter`/`--id` on a bare-ish `spt endpoint run`.
    pub fn new(
        project_name: String,
        adapter_rows: Vec<AdapterOption>,
        endpoints: Vec<EndpointRow>,
        prefill_adapter: Option<String>,
        prefill_id: Option<String>,
    ) -> Self {
        // A lone --adapter pre-selects its row; a lone --id pre-fills the id and
        // jumps straight into create-new.
        let adapter_cursor = prefill_adapter
            .as_deref()
            .and_then(|a| adapter_rows.iter().position(|r| r.address() == a || r.adapter == a))
            .unwrap_or(0);
        let screen = if prefill_id.is_some() || prefill_adapter.is_some() {
            Screen::CreateAdapter
        } else {
            Screen::Kind
        };
        Self {
            screen,
            project_name,
            kind_cursor: 0,
            adapter_rows,
            adapter_cursor,
            id_buffer: prefill_id.unwrap_or_default(),
            id_error: None,
            category: Category::Project,
            endpoints,
            item_cursor: 0,
            query: String::new(),
            filtering: false,
            confirm_cursor: 0,
        }
    }

    // ── Kind layer ────────────────────────────────────────────────────────
    pub fn kind_at_cursor(&self) -> Kind {
        if self.kind_cursor == 0 {
            Kind::CreateNew
        } else {
            Kind::PickExisting
        }
    }

    /// Enter from the kind layer into the chosen branch.
    pub fn enter_kind(&mut self) {
        match self.kind_at_cursor() {
            Kind::CreateNew => self.screen = Screen::CreateAdapter,
            Kind::PickExisting => {
                self.screen = Screen::PickExisting;
                self.item_cursor = 0;
            }
        }
    }

    // ── Generic cursor windowing ─────────────────────────────────────────
    /// Move a cursor within `[0, len)`, clamping at the ends (no wrap — matches
    /// the searchMultiselect windowed cursor; wrap would disorient the two-pane
    /// description sync).
    fn step(cursor: usize, len: usize, delta: isize) -> usize {
        if len == 0 {
            return 0;
        }
        let max = len - 1;
        let next = cursor as isize + delta;
        next.clamp(0, max as isize) as usize
    }

    // ── Create-new: adapter tree ─────────────────────────────────────────
    pub fn move_adapter(&mut self, delta: isize) {
        self.adapter_cursor = Self::step(self.adapter_cursor, self.adapter_rows.len(), delta);
    }

    pub fn selected_adapter(&self) -> Option<&AdapterOption> {
        self.adapter_rows.get(self.adapter_cursor)
    }

    /// Advance from adapter selection into id entry.
    pub fn enter_adapter(&mut self) {
        if self.selected_adapter().is_some() {
            self.screen = Screen::CreateId;
        }
    }

    // ── Create-new: id entry ─────────────────────────────────────────────
    /// Append a typed char to the id, re-validating the charset live.
    pub fn id_push(&mut self, c: char) {
        self.id_buffer.push(c);
        self.revalidate_id();
    }

    pub fn id_backspace(&mut self) {
        self.id_buffer.pop();
        self.revalidate_id();
    }

    fn revalidate_id(&mut self) {
        self.id_error = match spt_proto::id::validate_endpoint_id(&self.id_buffer) {
            Ok(()) => None,
            Err(e) => Some(e.to_string()),
        };
    }

    /// The create-new outcome (bare adapter address + typed id, fresh session,
    /// attach). `None` if the id is empty/invalid or no adapter is selected.
    pub fn create_outcome(&self) -> Option<Outcome> {
        if self.id_buffer.is_empty() || self.id_error.is_some() {
            return None;
        }
        let adapter = self.selected_adapter()?.address();
        Some(Outcome::Run {
            adapter,
            id: self.id_buffer.clone(),
            resume: None,
            mode: RunMode::Attach,
        })
    }

    // ── Pick-existing: category + list ───────────────────────────────────
    /// Cycle the category ring left (-1) or right (+1); resets the item cursor.
    pub fn move_category(&mut self, delta: isize) {
        let cur = Category::ALL.iter().position(|c| *c == self.category).unwrap_or(0);
        let len = Category::ALL.len() as isize;
        let next = ((cur as isize + delta) % len + len) % len; // category wraps
        self.category = Category::ALL[next as usize];
        self.item_cursor = 0;
    }

    /// Indices into `endpoints` visible in the current category, after the
    /// type-to-filter, ordered grouped-then-alpha (group asc, then id asc) with
    /// the fuzzy filter applied. The single source the view and the cursor share.
    // [impl->REQ-RUN-PICKER]
    pub fn visible_indices(&self) -> Vec<usize> {
        let want_local = self.category == Category::Local;
        // Project = local endpoints whose history names the cwd project; Local =
        // all local perches; Subnet = remote rows. (data.rs sets is_local +
        // group; the model just partitions.)
        let mut idx: Vec<usize> = self
            .endpoints
            .iter()
            .enumerate()
            .filter(|(_, e)| match self.category {
                Category::Local => e.is_local,
                Category::Subnet => !e.is_local,
                Category::Project => {
                    e.is_local && e.project_history.iter().any(|p| p == &self.project_name)
                }
            })
            .map(|(i, _)| i)
            .collect();
        let _ = want_local;
        // Grouped then alpha.
        idx.sort_by(|&a, &b| {
            let ea = &self.endpoints[a];
            let eb = &self.endpoints[b];
            ea.group.cmp(&eb.group).then(ea.id.cmp(&eb.id))
        });
        if self.query.is_empty() {
            return idx;
        }
        // Fuzzy-filter by id, preserving match-score order (nucleo).
        let mut matcher = Matcher::new(Config::DEFAULT);
        let pattern = Pattern::parse(&self.query, CaseMatching::Ignore, Normalization::Smart);
        let names: Vec<String> = idx.iter().map(|&i| self.endpoints[i].id.clone()).collect();
        let scored = pattern.match_list(names.iter().map(|s| s.as_str()), &mut matcher);
        // map matched names back to their endpoint indices, score order
        scored
            .into_iter()
            .filter_map(|(name, _)| {
                idx.iter().copied().find(|&i| self.endpoints[i].id == name)
            })
            .collect()
    }

    /// The endpoint under the item cursor (within the current visible set).
    pub fn selected_endpoint(&self) -> Option<&EndpointRow> {
        let vis = self.visible_indices();
        vis.get(self.item_cursor).map(|&i| &self.endpoints[i])
    }

    pub fn move_item(&mut self, delta: isize) {
        let len = self.visible_indices().len();
        self.item_cursor = Self::step(self.item_cursor, len, delta);
    }

    // ── Filter ───────────────────────────────────────────────────────────
    pub fn start_filter(&mut self) {
        self.filtering = true;
    }
    pub fn filter_push(&mut self, c: char) {
        self.query.push(c);
        self.item_cursor = 0;
    }
    pub fn filter_backspace(&mut self) {
        self.query.pop();
        self.item_cursor = 0;
    }
    pub fn end_filter(&mut self) {
        self.filtering = false;
    }

    /// Enter the confirm layer for the selected endpoint.
    pub fn enter_pick(&mut self) {
        if self.selected_endpoint().is_some() {
            self.screen = Screen::Confirm;
            self.confirm_cursor = 0;
        }
    }

    // ── Confirm layer ────────────────────────────────────────────────────
    /// The status-dependent option set for the selected endpoint (picker spec /
    /// doyle Q1/Q3). Order is stable so the cursor + the view agree.
    // [impl->REQ-RUN-PICKER]
    pub fn confirm_options(&self) -> Vec<ConfirmOption> {
        let Some(ep) = self.selected_endpoint() else {
            return Vec::new();
        };
        let mut opts = vec![ConfirmOption::Attach, ConfirmOption::Start, ConfirmOption::View];
        let offline = ep.status == EpStatus::Offline;
        // Resume-from-history: offline + LOCAL only, and only if the ledger has
        // rows (doyle Q3). A remote offline endpoint gets Instantiate instead.
        if offline && ep.is_local && !ep.resume_rows.is_empty() {
            opts.push(ConfirmOption::Resume);
        }
        // Change-harness-adapter: offline only.
        if offline {
            opts.push(ConfirmOption::ChangeAdapter);
        }
        // Instantiate-locally: a remote endpoint copied up here.
        if !ep.is_local {
            opts.push(ConfirmOption::Instantiate);
        }
        // Fork: always available.
        opts.push(ConfirmOption::Fork);
        // The `s` shortcut is always available (a keybind, listed last).
        opts.push(ConfirmOption::Shortcut);
        opts
    }

    pub fn move_confirm(&mut self, delta: isize) {
        let len = self.confirm_options().len();
        self.confirm_cursor = Self::step(self.confirm_cursor, len, delta);
    }

    pub fn selected_confirm(&self) -> Option<ConfirmOption> {
        self.confirm_options().get(self.confirm_cursor).copied()
    }

    /// Back out one screen; returns `true` if it backed out of the whole picker
    /// (the caller then yields `Outcome::Cancelled`).
    pub fn back(&mut self) -> bool {
        match self.screen {
            Screen::Kind => true,
            Screen::CreateAdapter => {
                self.screen = Screen::Kind;
                false
            }
            Screen::CreateId => {
                self.screen = Screen::CreateAdapter;
                false
            }
            Screen::PickExisting => {
                self.screen = Screen::Kind;
                false
            }
            Screen::Confirm => {
                self.screen = Screen::PickExisting;
                false
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn info(name: &str, shipped: &[&str], local: &[&str]) -> AdapterInfo {
        AdapterInfo {
            name: name.to_string(),
            shipped: shipped.iter().map(|s| s.to_string()).collect(),
            local: local.iter().map(|s| s.to_string()).collect(),
        }
    }

    fn ep(id: &str, group: &str, status: EpStatus, is_local: bool) -> EndpointRow {
        EndpointRow {
            id: id.to_string(),
            group: group.to_string(),
            node: "n".to_string(),
            status,
            is_local,
            adapter_profile: "claude-spt".to_string(),
            project_history: vec![],
            description: String::new(),
            resume_rows: vec![],
        }
    }

    // [unit->REQ-RUN-PICKER] the status→square mapping is the W2 duality (no blue).
    #[test]
    fn status_square_is_online_offline_only() {
        assert_eq!(EpStatus::Online.square(), glyph::ONLINE);
        assert_eq!(EpStatus::Offline.square(), glyph::OFFLINE);
    }

    // [unit->REQ-RUN-PICKER] the adapter tree nests profiles under each adapter,
    // bare-root first then shipped then local, each composite-addressable.
    #[test]
    fn adapter_tree_nests_profiles() {
        let rows = build_adapter_tree(&[
            info("claude-spt", &["fast"], &["mine"]),
            info("ccs", &[], &[]),
        ]);
        let addrs: Vec<String> = rows.iter().map(|r| r.address()).collect();
        assert_eq!(
            addrs,
            vec!["claude-spt", "claude-spt:fast", "claude-spt:mine", "ccs"]
        );
        assert!(!rows[0].is_leaf, "bare-adapter root is not a leaf");
        assert!(rows[1].is_leaf, "a profile is a leaf");
    }

    // [unit->REQ-RUN-PICKER] resume titles render `<project|trigger> @ ts (…id5)`.
    #[test]
    fn resume_title_render() {
        let with_proj = ResumeRow {
            session_id: "abcdef12345".to_string(),
            ts: "2026-06-14T10:00:00Z".to_string(),
            trigger: "clear".to_string(),
            project: "spt-core".to_string(),
        };
        assert_eq!(with_proj.title(), "spt-core @ 2026-06-14T10:00:00Z (…12345)");
        let no_proj = ResumeRow {
            session_id: "xy".to_string(),
            ts: "T".to_string(),
            trigger: "boot".to_string(),
            project: String::new(),
        };
        // short id (<5) takes the whole thing; no project falls back to trigger.
        assert_eq!(no_proj.title(), "boot @ T (…xy)");
    }

    // [unit->REQ-RUN-PICKER] kind layer routes into the right branch.
    #[test]
    fn kind_routes() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        assert_eq!(m.screen, Screen::Kind);
        m.kind_cursor = 0;
        m.enter_kind();
        assert_eq!(m.screen, Screen::CreateAdapter);
        m.back();
        m.kind_cursor = 1;
        m.enter_kind();
        assert_eq!(m.screen, Screen::PickExisting);
    }

    // [unit->REQ-RUN-PICKER] cursor windowing clamps (no wrap) at both ends.
    #[test]
    fn cursor_clamps() {
        assert_eq!(PickerModel::step(0, 3, -1), 0);
        assert_eq!(PickerModel::step(2, 3, 1), 2);
        assert_eq!(PickerModel::step(1, 3, 1), 2);
        assert_eq!(PickerModel::step(0, 0, 1), 0);
    }

    // [unit->REQ-RUN-PICKER] category nav wraps the ring + resets the item cursor.
    #[test]
    fn category_ring_wraps() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        assert_eq!(m.category, Category::Project);
        m.move_category(-1);
        assert_eq!(m.category, Category::Subnet, "left from first wraps to last");
        m.move_category(1);
        assert_eq!(m.category, Category::Project);
    }

    // [unit->REQ-RUN-PICKER] visible_indices partitions by category, grouped+alpha.
    #[test]
    fn visible_partitions_and_sorts() {
        let eps = vec![
            ep("zeta", "g1", EpStatus::Online, true),
            ep("alpha", "g1", EpStatus::Offline, true),
            ep("remote", "subnet:n", EpStatus::Online, false),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None);
        m.category = Category::Local;
        let vis = m.visible_indices();
        let ids: Vec<&str> = vis.iter().map(|&i| m.endpoints[i].id.as_str()).collect();
        assert_eq!(ids, vec!["alpha", "zeta"], "local, alpha within group");
        m.category = Category::Subnet;
        let ids: Vec<&str> = m
            .visible_indices()
            .iter()
            .map(|&i| m.endpoints[i].id.as_str())
            .collect();
        assert_eq!(ids, vec!["remote"]);
    }

    // [unit->REQ-RUN-PICKER] type-to-filter narrows the visible set.
    #[test]
    fn filter_narrows() {
        let eps = vec![
            ep("doyle", "g", EpStatus::Online, true),
            ep("todlando", "g", EpStatus::Online, true),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None);
        m.category = Category::Local;
        assert_eq!(m.visible_indices().len(), 2);
        m.start_filter();
        for c in "doy".chars() {
            m.filter_push(c);
        }
        let vis = m.visible_indices();
        assert_eq!(vis.len(), 1);
        assert_eq!(m.endpoints[vis[0]].id, "doyle");
    }

    // [unit->REQ-RUN-PICKER] confirm options are status-dependent: offline+local
    // with ledger rows gets Resume; online does not; remote gets Instantiate.
    #[test]
    fn confirm_options_status_dependent() {
        let mut offline_local = ep("a", "g", EpStatus::Offline, true);
        offline_local.resume_rows = vec![ResumeRow {
            session_id: "s".into(),
            ts: "t".into(),
            trigger: "boot".into(),
            project: String::new(),
        }];
        let mut m = PickerModel::new("p".into(), vec![], vec![offline_local], None, None);
        m.category = Category::Local;
        m.enter_pick();
        let opts = m.confirm_options();
        assert!(opts.contains(&ConfirmOption::Resume), "offline+local+ledger ⇒ Resume");
        assert!(opts.contains(&ConfirmOption::ChangeAdapter), "offline ⇒ ChangeAdapter");
        assert!(!opts.contains(&ConfirmOption::Instantiate), "local ⇒ no Instantiate");
        assert!(opts.contains(&ConfirmOption::Fork) && opts.contains(&ConfirmOption::Shortcut));

        // online local: no Resume, no ChangeAdapter.
        let online = ep("b", "g", EpStatus::Online, true);
        let mut m2 = PickerModel::new("p".into(), vec![], vec![online], None, None);
        m2.category = Category::Local;
        m2.enter_pick();
        let opts2 = m2.confirm_options();
        assert!(!opts2.contains(&ConfirmOption::Resume));
        assert!(!opts2.contains(&ConfirmOption::ChangeAdapter));

        // remote: Instantiate present.
        let remote = ep("c", "subnet:n", EpStatus::Online, false);
        let mut m3 = PickerModel::new("p".into(), vec![], vec![remote], None, None);
        m3.category = Category::Subnet;
        m3.enter_pick();
        assert!(m3.confirm_options().contains(&ConfirmOption::Instantiate));
    }

    // [unit->REQ-RUN-PICKER] create-new outcome bakes adapter+id, fresh, attach;
    // refuses an empty/invalid id.
    #[test]
    fn create_outcome_bakes_run() {
        let rows = build_adapter_tree(&[info("claude-spt", &["fast"], &[])]);
        let mut m = PickerModel::new("p".into(), rows, vec![], None, None);
        m.screen = Screen::CreateAdapter;
        m.adapter_cursor = 1; // claude-spt:fast
        m.enter_adapter();
        assert_eq!(m.screen, Screen::CreateId);
        assert!(m.create_outcome().is_none(), "empty id ⇒ no outcome");
        for c in "doyle".chars() {
            m.id_push(c);
        }
        match m.create_outcome().unwrap() {
            Outcome::Run { adapter, id, resume, mode } => {
                assert_eq!(adapter, "claude-spt:fast");
                assert_eq!(id, "doyle");
                assert_eq!(resume, None);
                assert_eq!(mode, RunMode::Attach);
            }
            other => panic!("expected Run, got {other:?}"),
        }
        // an invalid char sets the error and blocks the outcome.
        m.id_push(' ');
        assert!(m.id_error.is_some());
        assert!(m.create_outcome().is_none());
    }

    // [unit->REQ-RUN-PICKER] back navigation unwinds the screen graph, signalling
    // cancellation only from the kind layer.
    #[test]
    fn back_unwinds() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        m.screen = Screen::Confirm;
        assert!(!m.back());
        assert_eq!(m.screen, Screen::PickExisting);
        assert!(!m.back());
        assert_eq!(m.screen, Screen::Kind);
        assert!(m.back(), "back from kind cancels the picker");
    }

    // [unit->REQ-RUN-PICKER] a lone --id prefills and jumps into create-new.
    #[test]
    fn prefill_jumps_to_create() {
        let rows = build_adapter_tree(&[info("claude-spt", &[], &[])]);
        let m = PickerModel::new("p".into(), rows, vec![], None, Some("doyle".into()));
        assert_eq!(m.screen, Screen::CreateAdapter);
        assert_eq!(m.id_buffer, "doyle");
    }
}


Model + tests written. Now `data.rs` needs project-name + info adapter field + Status variants. Quick checks.


Grep

pub fn current_project|pub fn project_id|pub fn project_name|fn derive_project|pub struct.*Project|pub fn .*project


crates\spt-store\src\contextstore.rs:54:pub fn project_branch(project_id: &str) -> String {
crates\spt-store\src\contextstore.rs:89:pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
crates\spt-store\src\contextstore.rs:181:    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
crates\spt-store\src\contextstore.rs:208:    pub fn commit_project(
crates\spt-store\src\project.rs:64:pub fn project_id_for_dir(dir: &Path) -> String {


pub enum Status|Active|Dormant|Offline|Suspended|pub struct ResourceRow|pub fn resource_projection


13://! lagging node could re-announce a stale `Active` after the endpoint has actually gone
14://! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
17://! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
18://! `Active` (lower epoch). The epoch is the per-node monotonic counter
52:pub enum Status {
54:    Active,
56:    Dormant,
59:    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
63:    Suspended,
64:    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
66:    Offline,
295:    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
296:    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
319:    /// Whether an instance in this status is a valid routing target. `Offline` is not
320:    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.
322:        !matches!(self, Status::Offline)
331:pub struct ResourceRow {
344:/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
347:pub fn resource_projection(
400:    /// **different** node, including by an `Offline` instance (a resting endpoint still
614:    /// refuse if several live nodes remain (force `@node`). `Offline` instances are
784:        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
785:        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
796:            reg.merge_instance("ling", inst("nodeA", Status::Active, 1)),
800:            reg.merge_instance("ling", inst("nodeB", Status::Active, 1)),

[Showing results with pagination = limit: 25]


`project_id_for_dir` + `Status` 4 variants found. Check contextstore layout for best-effort project history.


40	pub const LIVE_ROLE_FILE: &str = "live-role.md";
41	/// Per-worktree directory holding surfaced concurrent-write conflict artifacts
42	/// (ADR-0013; tracked, so conflict state replicates).
43	pub const CONFLICT_DIR: &str = ".conflicts";
44	/// The per-(agent,project) context file inside a `p-<project>` worktree,
45	/// nested under the agent id.
46	pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";
47
48	/// The branch carrying `id`'s live context (the per-agent view).
49	pub fn agent_branch(id: &str) -> String {
50	    format!("a-{id}")
51	}
52
53	/// The branch carrying `project_id`'s per-project context (all agents).
54	pub fn project_branch(project_id: &str) -> String {
55	    format!("p-{project_id}")
56	}
57
58	/// Pure path of the seed bare repo under a `tracked/` root — the single
59	/// layout authority (6.1) shared by [`ContextStore::open_or_init_in`] and the
60	/// daemon's boot-time lock sweep (KH 1.3), which must reach the git-dir
61	/// without opening the store.
62	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
63	pub fn seed_git_dir(root: &Path) -> PathBuf {
64	    root.join(".seed.git")
65	}
66
67	/// Pure path of `id`'s live-context file under a `tracked/` root — the
68	/// **read** form (no branch/worktree creation; readers must not mint store
69	/// state). The single layout authority both the ensure-paths and every reader
70	/// route through (6.1).
71	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
72	pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
73	    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
74	}
75
76	/// Pure path of `id`'s live-**role** file under a `tracked/` root — the **read**
77	/// form (no branch/worktree creation). Sibling of [`live_context_file`] in the
78	/// same `a-<id>` worktree (`tracked/agents/<id>/live-role.md`). Single layout
79	/// authority both the ensure-path and every reader route through (6.1).
80	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
81	// [impl->REQ-EP-7]
82	pub fn live_role_file(root: &Path, id: &str) -> PathBuf {
83	    root.join("agents").join(id).join(LIVE_ROLE_FILE)
84	}
85
86	/// Pure path of the per-(agent,project) context file under a `tracked/` root —
87	/// the **read** form (no creation).
88	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
89	pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
90	    root.join("projects")
91	        .join(project_id)
92	        .join(id)
93	        .join(PROJECT_CONTEXT_FILE)
94	}
95
96	/// First 8 hex chars of the SHA-256 of `bytes` — the deterministic conflict
97	/// artifact name component (every node minting an artifact for the same
98	/// incoming version produces the same name → recording is idempotent).
99	fn content_hash8(bytes: &[u8]) -> String {
100	    use sha2::{Digest, Sha256};
101	    let digest = Sha256::digest(bytes);
102	    digest[..4].iter().map(|b| format!("{b:02x}")).collect()
103	}
104
105	/// The two-tier context store rooted at a `tracked/` directory.
106	pub struct ContextStore {
107	    store: BranchStore,
108	    root: PathBuf,
109	}
110
111	impl ContextStore {
112	    /// Open (or initialize) the node's canonical store at
113	    /// [`tracked_dir`](crate::perch::tracked_dir).
114	    pub fn open_or_init() -> std::io::Result<ContextStore> {
115	        Self::open_or_init_in(&tracked_dir())
116	    }
117
118	    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
119	    /// the testable seam.
120	    // [impl->REQ-STORE-1]
121	    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
122	        let store = BranchStore::open_or_init(&seed_git_dir(root))?;
123	        Ok(ContextStore {
124	            store,
125	            root: root.to_path_buf(),
126	        })
127	    }
128
129	    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
130	    pub fn branch_store(&self) -> &BranchStore {
131	        &self.store
132	    }
133
134	    /// The `tracked/` root this store lives under.
135	    pub fn root(&self) -> &Path {
136	        &self.root
137	    }
138
139	    fn validated(id: &str) -> std::io::Result<()> {
140	        validate_endpoint_id(id)
141	            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
142	    }
143
144	    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
145	    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
146	    /// charset-validated (4.6) — branch names and worktree dirs are derived
147	    /// from it.
148	    // [impl->REQ-STORE-1]
149	    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {

pub const LIVE_ROLE_FILE: &str = "live-role.md";
/// Per-worktree directory holding surfaced concurrent-write conflict artifacts
/// (ADR-0013; tracked, so conflict state replicates).
pub const CONFLICT_DIR: &str = ".conflicts";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// Pure path of the seed bare repo under a `tracked/` root — the single
/// layout authority (6.1) shared by [`ContextStore::open_or_init_in`] and the
/// daemon's boot-time lock sweep (KH 1.3), which must reach the git-dir
/// without opening the store.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn seed_git_dir(root: &Path) -> PathBuf {
    root.join(".seed.git")
}

/// Pure path of `id`'s live-context file under a `tracked/` root — the
/// **read** form (no branch/worktree creation; readers must not mint store
/// state). The single layout authority both the ensure-paths and every reader
/// route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
}

/// Pure path of `id`'s live-**role** file under a `tracked/` root — the **read**
/// form (no branch/worktree creation). Sibling of [`live_context_file`] in the
/// same `a-<id>` worktree (`tracked/agents/<id>/live-role.md`). Single layout
/// authority both the ensure-path and every reader route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-EP-7]
pub fn live_role_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_ROLE_FILE)
}

/// Pure path of the per-(agent,project) context file under a `tracked/` root —
/// the **read** form (no creation).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
    root.join("projects")
        .join(project_id)
        .join(id)
        .join(PROJECT_CONTEXT_FILE)
}

/// First 8 hex chars of the SHA-256 of `bytes` — the deterministic conflict
/// artifact name component (every node minting an artifact for the same
/// incoming version produces the same name → recording is idempotent).
fn content_hash8(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let digest = Sha256::digest(bytes);
    digest[..4].iter().map(|b| format!("{b:02x}")).collect()
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&seed_git_dir(root))?;
        Ok(ContextStore {
            store,
            root: root.to_path_buf(),
        })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {


Layout: `tracked/projects/<project_id>/<id>/`. Check info.json fields (adapter) + tracked_dir + perch path helpers.


pub struct InfoRecord|pub adapter|pub home_subnet|pub resources|pub state|pub session_id|pub fn tracked_dir|pub fn resolve_perch_path|pub fn owlery_dir


crates\spt-store\src\xfer.rs:61:    pub state: String,
crates\spt-store\src\shellinfo.rs:50:    pub adapter_name: String,
crates\spt-store\src\perch.rs:66:pub fn owlery_dir() -> PathBuf {
crates\spt-store\src\perch.rs:75:pub fn tracked_dir() -> PathBuf {
crates\spt-store\src\perch.rs:308:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates\spt-store\src\perch.rs:320:pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates\spt-store\src\seed.rs:29:    pub session_id: String,
crates\spt-store\src\seed.rs:31:    pub adapter: String,
crates\spt-store\src\info.rs:46:    pub session_id: String,
crates\spt-store\src\info.rs:48:    pub state: String,
crates\spt-store\src\info.rs:107:    pub resources: Option<String>,
crates\spt-store\src\info.rs:116:    pub home_subnet: Option<String>,
crates\spt-store\src\info.rs:123:    pub adapter: Option<String>,
crates\spt-store\src\sessions.rs:63:    pub session_id: String,


info has `adapter: Option<String>`. Writing `data.rs`. Mark model/deps done.


Updated task #4 status


Updated task #5 status


commit as needed to keep code history tidy.