1	//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
2	//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
3	//! sessions.log`.
4	//!
5	//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
6	//! rotates its harness `session_id` (a new transcript file). The single
7	//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
8	//! the thread but cannot *span* it. This ledger records each rotation so the
9	//! digest can enumerate the **last K sessions** and bridge a boundary
10	//! (ADR-0019 §thread-spanning).
11	//!
12	//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
13	//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
14	//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
15	//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
16	//! is deduped.
17	// [impl->REQ-TERM-6]
18
19	use std::io;
20	use std::path::Path;
21
22	use serde::{Deserialize, Serialize};
23
24	use crate::atomic::atomic_write_string;
25
26	/// The ledger filename under a perch.
27	pub const LEDGER_FILE: &str = "sessions.log";
28
29	/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
30	/// append so the file can never grow without bound (risk #2).
31	pub const MAX_LEDGER: usize = 64;
32
33	/// Why a session became active.
34	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35	#[serde(rename_all = "lowercase")]
36	pub enum SessionTrigger {
37	    /// First bind of the perch (`api listen` / revive).
38	    Boot,
39	    /// A `/clear` boundary (`api boundary clear`).
40	    Clear,
41	    /// A `/compact` boundary (`api boundary compact`).
42	    Compact,
43	}
44
45	impl SessionTrigger {
46	    /// The lowercase token (matches the serde wire form) — used as the boundary
47	    /// marker `kind` in the spanned digest.
48	    pub fn token(self) -> &'static str {
49	        match self {
50	            SessionTrigger::Boot => "boot",
51	            SessionTrigger::Clear => "clear",
52	            SessionTrigger::Compact => "compact",
53	        }
54	    }
55	}
56
57	/// One ledger row: a `session_id` became active at `ts` via `trigger`.
58	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59	pub struct SessionEntry {
60	    /// RFC3339-UTC instant (see [`crate::timefmt`]).
61	    pub ts: String,
62	    /// The harness session id that became active.
63	    pub session_id: String,
64	    /// What caused the rotation.
65	    pub trigger: SessionTrigger,
66	}
67
68	/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
69	/// line is skipped, the rest still load (the degrade-don't-crash store stance).
70	pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
71	    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
72	        return Vec::new();
73	    };
74	    s.lines()
75	        .filter(|l| !l.trim().is_empty())
76	        .filter_map(|l| serde_json::from_str(l).ok())
77	        .collect()
78	}
79
80	/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
81	pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
82	    if k == 0 {
83	        return Vec::new();
84	    }
85	    let all = read_all(perch);
86	    let start = all.len().saturating_sub(k);
87	    all[start..].to_vec()
88	}
89
90	/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
91	/// re-bind of the live session is not a new boundary) and bounding the ledger to
92	/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
93	/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
94	/// partial-line corruption). Returns `true` if a row was written, `false` on a
95	/// same-session dedup.
96	// [impl->REQ-TERM-6]
97	pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
98	    let mut entries = read_all(perch);
99	    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
100	        return Ok(false); // same session re-bound — not a new boundary
101	    }
102	    entries.push(entry.clone());
103	    let start = entries.len().saturating_sub(MAX_LEDGER);
104	    let body: String = entries[start..]
105	        .iter()
106	        .map(|e| serde_json::to_string(e).unwrap_or_default())
107	        .map(|l| l + "\n")
108	        .collect();
109	    std::fs::create_dir_all(perch)?;
110	    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
111	    Ok(true)
112	}
113
114	#[cfg(test)]
115	mod tests {
116	    use super::*;
117
118	    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
119	        SessionEntry {
120	            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
121	            session_id: sid.to_string(),
122	            trigger,
123	        }
124	    }
125
126	    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
127	    #[test]
128	    fn appends_and_reads_in_order() {
129	        let dir = tempfile::tempdir().unwrap();
130	        let perch = dir.path().join("perch");
131	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
132	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
133	        let all = read_all(&perch);
134	        assert_eq!(all.len(), 2);
135	        assert_eq!(all[0].session_id, "s1");
136	        assert_eq!(all[0].trigger, SessionTrigger::Boot);
137	        assert_eq!(all[1].session_id, "s2");
138	        assert_eq!(all[1].trigger, SessionTrigger::Clear);
139	    }
140
141	    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
142	    // boundary); a different session appends.
143	    #[test]
144	    fn same_session_rebind_is_deduped() {
145	        let dir = tempfile::tempdir().unwrap();
146	        let perch = dir.path().join("perch");
147	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
148	        assert!(
149	            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
150	            "same session re-bind → no new row"
151	        );
152	        assert_eq!(read_all(&perch).len(), 1);
153	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
154	        assert_eq!(read_all(&perch).len(), 2);
155	    }
156
157	    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
158	    // it never grows past MAX_LEDGER.
159	    #[test]
160	    fn last_k_and_bounding() {
161	        let dir = tempfile::tempdir().unwrap();
162	        let perch = dir.path().join("perch");
163	        for i in 0..(MAX_LEDGER + 10) {
164	            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
165	        }
166	        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
167	        let last3 = last_k(&perch, 3);
168	        assert_eq!(last3.len(), 3);
169	        let n = MAX_LEDGER + 10;
170	        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
171	        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
172	        assert!(last_k(&perch, 0).is_empty());
173	    }
174
175	    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
176	    #[test]
177	    fn tolerant_of_garbage_lines() {
178	        let dir = tempfile::tempdir().unwrap();
179	        let perch = dir.path().join("perch");
180	        std::fs::create_dir_all(&perch).unwrap();
181	        std::fs::write(
182	            perch.join(LEDGER_FILE),
183	            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
184	        )
185	        .unwrap();
186	        let all = read_all(&perch);
187	        assert_eq!(all.len(), 1);
188	        assert_eq!(all[0].session_id, "good");
189	    }
190	}
191

//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
//! sessions.log`.
//!
//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
//! rotates its harness `session_id` (a new transcript file). The single
//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
//! the thread but cannot *span* it. This ledger records each rotation so the
//! digest can enumerate the **last K sessions** and bridge a boundary
//! (ADR-0019 §thread-spanning).
//!
//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
//! is deduped.
// [impl->REQ-TERM-6]

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

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;

/// The ledger filename under a perch.
pub const LEDGER_FILE: &str = "sessions.log";

/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
/// append so the file can never grow without bound (risk #2).
pub const MAX_LEDGER: usize = 64;

/// Why a session became active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionTrigger {
    /// First bind of the perch (`api listen` / revive).
    Boot,
    /// A `/clear` boundary (`api boundary clear`).
    Clear,
    /// A `/compact` boundary (`api boundary compact`).
    Compact,
}

impl SessionTrigger {
    /// The lowercase token (matches the serde wire form) — used as the boundary
    /// marker `kind` in the spanned digest.
    pub fn token(self) -> &'static str {
        match self {
            SessionTrigger::Boot => "boot",
            SessionTrigger::Clear => "clear",
            SessionTrigger::Compact => "compact",
        }
    }
}

/// One ledger row: a `session_id` became active at `ts` via `trigger`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEntry {
    /// RFC3339-UTC instant (see [`crate::timefmt`]).
    pub ts: String,
    /// The harness session id that became active.
    pub session_id: String,
    /// What caused the rotation.
    pub trigger: SessionTrigger,
}

/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
/// line is skipped, the rest still load (the degrade-don't-crash store stance).
pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
        return Vec::new();
    };
    s.lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str(l).ok())
        .collect()
}

/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
    if k == 0 {
        return Vec::new();
    }
    let all = read_all(perch);
    let start = all.len().saturating_sub(k);
    all[start..].to_vec()
}

/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
/// re-bind of the live session is not a new boundary) and bounding the ledger to
/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
/// partial-line corruption). Returns `true` if a row was written, `false` on a
/// same-session dedup.
// [impl->REQ-TERM-6]
pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
    let mut entries = read_all(perch);
    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
        return Ok(false); // same session re-bound — not a new boundary
    }
    entries.push(entry.clone());
    let start = entries.len().saturating_sub(MAX_LEDGER);
    let body: String = entries[start..]
        .iter()
        .map(|e| serde_json::to_string(e).unwrap_or_default())
        .map(|l| l + "\n")
        .collect();
    std::fs::create_dir_all(perch)?;
    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
    Ok(true)
}

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

    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
        SessionEntry {
            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
            session_id: sid.to_string(),
            trigger,
        }
    }

    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
    #[test]
    fn appends_and_reads_in_order() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        let all = read_all(&perch);
        assert_eq!(all.len(), 2);
        assert_eq!(all[0].session_id, "s1");
        assert_eq!(all[0].trigger, SessionTrigger::Boot);
        assert_eq!(all[1].session_id, "s2");
        assert_eq!(all[1].trigger, SessionTrigger::Clear);
    }

    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
    // boundary); a different session appends.
    #[test]
    fn same_session_rebind_is_deduped() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(
            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
            "same session re-bind → no new row"
        );
        assert_eq!(read_all(&perch).len(), 1);
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        assert_eq!(read_all(&perch).len(), 2);
    }

    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
    // it never grows past MAX_LEDGER.
    #[test]
    fn last_k_and_bounding() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        for i in 0..(MAX_LEDGER + 10) {
            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
        }
        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
        let last3 = last_k(&perch, 3);
        assert_eq!(last3.len(), 3);
        let n = MAX_LEDGER + 10;
        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
        assert!(last_k(&perch, 0).is_empty());
    }

    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
    #[test]
    fn tolerant_of_garbage_lines() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(
            perch.join(LEDGER_FILE),
            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
        )
        .unwrap();
        let all = read_all(&perch);
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].session_id, "good");
    }
}


1	//! Perch enumeration + self-identification helpers for the `list`, `whoami`,
2	//! and `stop` subcommands. M1 enumerates **flat** (Self_) perches only —
3	//! nested Psyche/Worker perches arrive with the M2 lifecycle.
4	//!
5	//! These are CLI-layer conveniences: they read the on-disk owlery layout and
6	//! the registry through the spt-store API. Nothing here is on the hot
7	//! delivery path.
8
9	use spt_store::info;
10	use spt_store::liveness;
11	use spt_store::perch::{self, ParentHint};
12	use spt_store::registry;
13
14	/// A snapshot of one perch for `spt list`.
15	pub struct PerchEntry {
16	    pub id: String,
17	    pub state: String,
18	    pub address: Option<String>,
19	    pub ready: bool,
20	    pub alive: bool,
21	}
22
23	/// Enumerate flat perches under the owlery: any top-level dir holding an
24	/// `info.json`. Returns them sorted by id.
25	pub fn enumerate() -> Vec<PerchEntry> {
26	    let owlery = perch::owlery_dir();
27	    let mut out = Vec::new();
28	    let Ok(entries) = std::fs::read_dir(&owlery) else {
29	        return out;
30	    };
31	    for entry in entries.flatten() {
32	        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
33	            continue;
34	        }
35	        let perch_path = entry.path();
36	        let Some(rec) = info::read_info(&perch_path) else {
37	            continue; // not a perch (no info.json)
38	        };
39	        let id = rec.id.clone();
40	        let ready = perch::resolve_ready_file(&id, ParentHint::Infer).exists();
41	        // Single liveness resolver (2.5): daemon-authoritative `status` for
42	        // daemon-hosted perches, per-pid probe for external/interim ones.
43	        let alive = liveness::is_perch_alive(&perch_path);
44	        let address = registry::lookup_address(&id, &owlery);
45	        out.push(PerchEntry {
46	            id,
47	            state: rec.state,
48	            address,
49	            ready,
50	            alive,
51	        });
52	    }
53	    out.sort_by(|a, b| a.id.cmp(&b.id));
54	    out
55	}
56
57	/// Identify the perch owned by *this* session: the one whose `info.json`
58	/// `session_id` matches `$OWL_SESSION_ID`. Falls back to `$SPT_AGENT_ID` if
59	/// that env is set. `None` if neither resolves.
60	pub fn detect_self_id() -> Option<String> {
61	    if let Ok(session) = std::env::var("OWL_SESSION_ID") {
62	        if !session.is_empty() {
63	            let owlery = perch::owlery_dir();
64	            if let Ok(entries) = std::fs::read_dir(&owlery) {
65	                for entry in entries.flatten() {
66	                    if let Some(rec) = info::read_info(&entry.path()) {
67	                        if rec.session_id == session {
68	                            return Some(rec.id);
69	                        }
70	                    }
71	                }
72	            }
73	        }
74	    }
75	    std::env::var("SPT_AGENT_ID").ok().filter(|s| !s.is_empty())
76	}
77

//! Perch enumeration + self-identification helpers for the `list`, `whoami`,
//! and `stop` subcommands. M1 enumerates **flat** (Self_) perches only —
//! nested Psyche/Worker perches arrive with the M2 lifecycle.
//!
//! These are CLI-layer conveniences: they read the on-disk owlery layout and
//! the registry through the spt-store API. Nothing here is on the hot
//! delivery path.

use spt_store::info;
use spt_store::liveness;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

/// A snapshot of one perch for `spt list`.
pub struct PerchEntry {
    pub id: String,
    pub state: String,
    pub address: Option<String>,
    pub ready: bool,
    pub alive: bool,
}

/// Enumerate flat perches under the owlery: any top-level dir holding an
/// `info.json`. Returns them sorted by id.
pub fn enumerate() -> Vec<PerchEntry> {
    let owlery = perch::owlery_dir();
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(&owlery) else {
        return out;
    };
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let perch_path = entry.path();
        let Some(rec) = info::read_info(&perch_path) else {
            continue; // not a perch (no info.json)
        };
        let id = rec.id.clone();
        let ready = perch::resolve_ready_file(&id, ParentHint::Infer).exists();
        // Single liveness resolver (2.5): daemon-authoritative `status` for
        // daemon-hosted perches, per-pid probe for external/interim ones.
        let alive = liveness::is_perch_alive(&perch_path);
        let address = registry::lookup_address(&id, &owlery);
        out.push(PerchEntry {
            id,
            state: rec.state,
            address,
            ready,
            alive,
        });
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

/// Identify the perch owned by *this* session: the one whose `info.json`
/// `session_id` matches `$OWL_SESSION_ID`. Falls back to `$SPT_AGENT_ID` if
/// that env is set. `None` if neither resolves.
pub fn detect_self_id() -> Option<String> {
    if let Ok(session) = std::env::var("OWL_SESSION_ID") {
        if !session.is_empty() {
            let owlery = perch::owlery_dir();
            if let Ok(entries) = std::fs::read_dir(&owlery) {
                for entry in entries.flatten() {
                    if let Some(rec) = info::read_info(&entry.path()) {
                        if rec.session_id == session {
                            return Some(rec.id);
                        }
                    }
                }
            }
        }
    }
    std::env::var("SPT_AGENT_ID").ok().filter(|s| !s.is_empty())
}


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


Ledger takes perch `&Path`, no project field per row. Read registry profile-enum + resolve_option, rc signature, cli Run dispatch + adapter-list.


410	// ───────────────────────── Local profiles (M9 / REQ-MANIFEST-2) ─────────────
411	//
412	// A **local** profile is a node-local, user-authored sparse overlay registered
413	// *beside* the adapter at `adapters/<adapter>/profiles/<profile>.toml`. Because
414	// `register` never clears that dir, locals **survive adapter re-registration**
415	// (the manifest copy is adapter-owned and overwritten by updates; the user's
416	// customization is not). Locals may not shadow a shipped profile's name (refused
417	// at create) and are floored tighten-only at create. CLI verbs (`create-profile`,
418	// `delete-profile`, `set-string`) operate on locals only.
419
420	/// `adapters/<adapter>/profiles/`.
421	fn local_profiles_dir(adapters_dir: &Path, adapter: &str) -> PathBuf {
422	    adapters_dir.join(adapter).join("profiles")
423	}
424
425	/// `adapters/<adapter>/profiles/<profile>.toml`.
426	fn local_profile_file(adapters_dir: &Path, adapter: &str, profile: &str) -> PathBuf {
427	    local_profiles_dir(adapters_dir, adapter).join(format!("{profile}.toml"))
428	}
429
430	/// A profile name must be a single, safe path segment usable in the composite
431	/// `<adapter>:<profile>` address: non-empty, no `:` (the address separator), no
432	/// path separators, no whitespace, and not a dot-segment.
433	fn validate_profile_name(profile: &str) -> Result<(), RegistryError> {
434	    let bad = profile.is_empty()
435	        || profile == "."
436	        || profile == ".."
437	        || profile
438	            .chars()
439	            .any(|c| c == ':' || c == '/' || c == '\\' || c.is_whitespace());
440	    if bad {
441	        return Err(RegistryError::InvalidProfileName(profile.to_string()));
442	    }
443	    Ok(())
444	}
445
446	/// The local profile overlay [`Value`] for `<adapter>/profiles/<profile>.toml`.
447	pub fn load_local_overlay(
448	    adapters_dir: &Path,
449	    adapter: &str,
450	    profile: &str,
451	) -> Result<Value, RegistryError> {
452	    let path = local_profile_file(adapters_dir, adapter, profile);
453	    if !path.exists() {
454	        return Err(RegistryError::ProfileNotFound(profile.to_string()));
455	    }
456	    let s = std::fs::read_to_string(&path)?;
457	    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(format!("profiles/{profile}")))
458	}
459
460	/// Local profile names registered for `adapter`, sorted (drops unreadable
461	/// entries — the tolerant-registry stance).
462	pub fn local_profile_names(adapters_dir: &Path, adapter: &str) -> Vec<String> {
463	    let Ok(entries) = std::fs::read_dir(local_profiles_dir(adapters_dir, adapter)) else {
464	        return Vec::new();
465	    };
466	    let mut out: Vec<String> = entries
467	        .filter_map(|e| e.ok())
468	        .filter_map(|e| {
469	            let name = e.file_name().to_string_lossy().into_owned();
470	            name.strip_suffix(".toml").map(str::to_string)
471	        })
472	        .collect();
473	    out.sort();
474	    out
475	}
476
477	/// Create (or overwrite) a local profile for `adapter` from a sparse overlay TOML
478	/// document. Refuses an invalid name, a name that shadows a **shipped** profile,
479	/// an overlay that fails to parse, or one that — once merged onto the parent —
480	/// breaks a manifest invariant or **loosens a consent floor** (tighten-only).
481	/// Nothing is written unless every check passes.
482	// [impl->REQ-MANIFEST-2]
483	pub fn create_local_profile(
484	    adapters_dir: &Path,
485	    adapter: &str,
486	    profile: &str,
487	    overlay_toml: &str,
488	) -> Result<(), RegistryError> {
489	    validate_profile_name(profile)?;
490	    let record = load_record(adapters_dir, adapter)?;
491	    let parent = load_manifest(adapters_dir, &record)?;
492	    if parent.profiles.contains_key(profile) {
493	        return Err(RegistryError::ProfileShadowsShipped(profile.to_string()));
494	    }
495	    let overlay: Value = toml::from_str(overlay_toml)
496	        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Parse(e)))?;
497	    // Validate by resolving: merge onto the parent, run manifest invariants + the
498	    // tighten-only floor. A failure refuses the create (nothing written).
499	    profile::resolve_overlay(&parent, &overlay).map_err(RegistryError::Manifest)?;
500	    let dir = local_profiles_dir(adapters_dir, adapter);
501	    std::fs::create_dir_all(&dir)?;
502	    atomic_write_string(&local_profile_file(adapters_dir, adapter, profile), overlay_toml)?;
503	    Ok(())
504	}
505
506	/// Delete a local profile. Refuses a **shipped** profile name (adapter-owned,
507	/// immutable) and errors if no local file exists.
508	// [impl->REQ-MANIFEST-2]
509	pub fn delete_local_profile(
510	    adapters_dir: &Path,
511	    adapter: &str,
512	    profile: &str,
513	) -> Result<(), RegistryError> {
514	    let record = load_record(adapters_dir, adapter)?;
515	    let parent = load_manifest(adapters_dir, &record)?;
516	    if parent.profiles.contains_key(profile) {
517	        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
518	    }
519	    let path = local_profile_file(adapters_dir, adapter, profile);
520	    if !path.exists() {
521	        return Err(RegistryError::ProfileNotFound(profile.to_string()));
522	    }
523	    std::fs::remove_file(&path)?;
524	    Ok(())
525	}
526
527	/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the
528	/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the
529	/// option (bare = parent, else shipped/local overlay) exactly as every other
530	/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =
531	/// the key is absent (a clean "not set", distinct from an unresolvable option).
532	// [impl->REQ-MANIFEST-3]
533	pub fn get_string(
534	    adapters_dir: &Path,
535	    option: &str,
536	    key_path: &str,
537	) -> Result<Option<Value>, RegistryError> {
538	    let (record, manifest) = resolve_option(adapters_dir, option)?;
539	    let Some(value) = profile::string_at(&manifest.strings, key_path) else {
540	        return Ok(None);
541	    };
542	    // A literal value reads as-is. A single-key `{file=…}` pointer resolves to
543	    // the file's contents (REQ-MANIFEST-5), lazily — so live edits reflect.
544	    let Some(rel) = profile::as_file_pointer(value) else {
545	        return Ok(Some(value.clone()));
546	    };
547	    // Provenance (the update-safety guard): if this key is supplied as a file
548	    // pointer by a LOCAL profile, resolve against its user-owned dir (survives
549	    // adapter updates); otherwise against the adapter-shipped `strings/` dir.
550	    let (adapter, profile_seg) = profile::split_option(option);
551	    let base = match profile_seg {
552	        Some(p) if local_profile_supplies_pointer(adapters_dir, adapter, p, key_path) => {
553	            local_profiles_dir(adapters_dir, adapter)
554	        }
555	        _ => shipped_strings_dir(adapters_dir, &record),
556	    };
557	    Ok(read_string_file(&base, rel))
558	}
559
560	/// Whether `<adapter>:<profile>`'s LOCAL overlay file itself supplies `key_path`
561	/// as a file pointer — the provenance test that routes a pointer to the
562	/// user-owned profiles dir instead of the adapter-shipped `strings/` dir
563	/// (REQ-MANIFEST-5 update-safety). A shipped profile (no local file) is `false`.
564	fn local_profile_supplies_pointer(
565	    adapters_dir: &Path,
566	    adapter: &str,
567	    profile: &str,
568	    key_path: &str,
569	) -> bool {
570	    let Ok(overlay) = load_local_overlay(adapters_dir, adapter, profile) else {
571	        return false;
572	    };
573	    overlay
574	        .as_table()
575	        .and_then(|t| t.get("strings"))
576	        .and_then(|s| s.as_table())
577	        .and_then(|st| profile::string_at(st, key_path))
578	        .map(|v| profile::as_file_pointer(v).is_some())
579	        .unwrap_or(false)

// ───────────────────────── Local profiles (M9 / REQ-MANIFEST-2) ─────────────
//
// A **local** profile is a node-local, user-authored sparse overlay registered
// *beside* the adapter at `adapters/<adapter>/profiles/<profile>.toml`. Because
// `register` never clears that dir, locals **survive adapter re-registration**
// (the manifest copy is adapter-owned and overwritten by updates; the user's
// customization is not). Locals may not shadow a shipped profile's name (refused
// at create) and are floored tighten-only at create. CLI verbs (`create-profile`,
// `delete-profile`, `set-string`) operate on locals only.

/// `adapters/<adapter>/profiles/`.
fn local_profiles_dir(adapters_dir: &Path, adapter: &str) -> PathBuf {
    adapters_dir.join(adapter).join("profiles")
}

/// `adapters/<adapter>/profiles/<profile>.toml`.
fn local_profile_file(adapters_dir: &Path, adapter: &str, profile: &str) -> PathBuf {
    local_profiles_dir(adapters_dir, adapter).join(format!("{profile}.toml"))
}

/// A profile name must be a single, safe path segment usable in the composite
/// `<adapter>:<profile>` address: non-empty, no `:` (the address separator), no
/// path separators, no whitespace, and not a dot-segment.
fn validate_profile_name(profile: &str) -> Result<(), RegistryError> {
    let bad = profile.is_empty()
        || profile == "."
        || profile == ".."
        || profile
            .chars()
            .any(|c| c == ':' || c == '/' || c == '\\' || c.is_whitespace());
    if bad {
        return Err(RegistryError::InvalidProfileName(profile.to_string()));
    }
    Ok(())
}

/// The local profile overlay [`Value`] for `<adapter>/profiles/<profile>.toml`.
pub fn load_local_overlay(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
) -> Result<Value, RegistryError> {
    let path = local_profile_file(adapters_dir, adapter, profile);
    if !path.exists() {
        return Err(RegistryError::ProfileNotFound(profile.to_string()));
    }
    let s = std::fs::read_to_string(&path)?;
    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(format!("profiles/{profile}")))
}

/// Local profile names registered for `adapter`, sorted (drops unreadable
/// entries — the tolerant-registry stance).
pub fn local_profile_names(adapters_dir: &Path, adapter: &str) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(local_profiles_dir(adapters_dir, adapter)) else {
        return Vec::new();
    };
    let mut out: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let name = e.file_name().to_string_lossy().into_owned();
            name.strip_suffix(".toml").map(str::to_string)
        })
        .collect();
    out.sort();
    out
}

/// Create (or overwrite) a local profile for `adapter` from a sparse overlay TOML
/// document. Refuses an invalid name, a name that shadows a **shipped** profile,
/// an overlay that fails to parse, or one that — once merged onto the parent —
/// breaks a manifest invariant or **loosens a consent floor** (tighten-only).
/// Nothing is written unless every check passes.
// [impl->REQ-MANIFEST-2]
pub fn create_local_profile(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
    overlay_toml: &str,
) -> Result<(), RegistryError> {
    validate_profile_name(profile)?;
    let record = load_record(adapters_dir, adapter)?;
    let parent = load_manifest(adapters_dir, &record)?;
    if parent.profiles.contains_key(profile) {
        return Err(RegistryError::ProfileShadowsShipped(profile.to_string()));
    }
    let overlay: Value = toml::from_str(overlay_toml)
        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Parse(e)))?;
    // Validate by resolving: merge onto the parent, run manifest invariants + the
    // tighten-only floor. A failure refuses the create (nothing written).
    profile::resolve_overlay(&parent, &overlay).map_err(RegistryError::Manifest)?;
    let dir = local_profiles_dir(adapters_dir, adapter);
    std::fs::create_dir_all(&dir)?;
    atomic_write_string(&local_profile_file(adapters_dir, adapter, profile), overlay_toml)?;
    Ok(())
}

/// Delete a local profile. Refuses a **shipped** profile name (adapter-owned,
/// immutable) and errors if no local file exists.
// [impl->REQ-MANIFEST-2]
pub fn delete_local_profile(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
) -> Result<(), RegistryError> {
    let record = load_record(adapters_dir, adapter)?;
    let parent = load_manifest(adapters_dir, &record)?;
    if parent.profiles.contains_key(profile) {
        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
    }
    let path = local_profile_file(adapters_dir, adapter, profile);
    if !path.exists() {
        return Err(RegistryError::ProfileNotFound(profile.to_string()));
    }
    std::fs::remove_file(&path)?;
    Ok(())
}

/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the
/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the
/// option (bare = parent, else shipped/local overlay) exactly as every other
/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =
/// the key is absent (a clean "not set", distinct from an unresolvable option).
// [impl->REQ-MANIFEST-3]
pub fn get_string(
    adapters_dir: &Path,
    option: &str,
    key_path: &str,
) -> Result<Option<Value>, RegistryError> {
    let (record, manifest) = resolve_option(adapters_dir, option)?;
    let Some(value) = profile::string_at(&manifest.strings, key_path) else {
        return Ok(None);
    };
    // A literal value reads as-is. A single-key `{file=…}` pointer resolves to
    // the file's contents (REQ-MANIFEST-5), lazily — so live edits reflect.
    let Some(rel) = profile::as_file_pointer(value) else {
        return Ok(Some(value.clone()));
    };
    // Provenance (the update-safety guard): if this key is supplied as a file
    // pointer by a LOCAL profile, resolve against its user-owned dir (survives
    // adapter updates); otherwise against the adapter-shipped `strings/` dir.
    let (adapter, profile_seg) = profile::split_option(option);
    let base = match profile_seg {
        Some(p) if local_profile_supplies_pointer(adapters_dir, adapter, p, key_path) => {
            local_profiles_dir(adapters_dir, adapter)
        }
        _ => shipped_strings_dir(adapters_dir, &record),
    };
    Ok(read_string_file(&base, rel))
}

/// Whether `<adapter>:<profile>`'s LOCAL overlay file itself supplies `key_path`
/// as a file pointer — the provenance test that routes a pointer to the
/// user-owned profiles dir instead of the adapter-shipped `strings/` dir
/// (REQ-MANIFEST-5 update-safety). A shipped profile (no local file) is `false`.
fn local_profile_supplies_pointer(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
    key_path: &str,
) -> bool {
    let Ok(overlay) = load_local_overlay(adapters_dir, adapter, profile) else {
        return false;
    };
    overlay
        .as_table()
        .and_then(|t| t.get("strings"))
        .and_then(|s| s.as_table())
        .and_then(|st| profile::string_at(st, key_path))
        .map(|v| profile::as_file_pointer(v).is_some())
        .unwrap_or(false)


920	        // The endpoint noun (M8 decisions 1–2): bare = the merged list.
921	        // [impl->REQ-CLI-1]
922	        Cmd::Endpoint { action } => match action.unwrap_or(EndpointCmd::List {
923	            local: false,
924	            subnet: None,
925	            detail: false,
926	        }) {
927	            EndpointCmd::List {
928	                local,
929	                subnet,
930	                detail,
931	            } => cmd_endpoint_list(local, subnet, detail),
932	            EndpointCmd::Fork {
933	                src,
934	                new_id,
935	                subnet,
936	                delete_source,
937	            } => cmd_fork(&src, &new_id, &subnet, delete_source),
938	            EndpointCmd::Suspend { id } => {
939	                cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED")
940	            }
941	            EndpointCmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
942	            EndpointCmd::Shutdown { id } => cmd_shutdown(id),
943	            EndpointCmd::Stop { id } => cmd_stop(&id),
944	            EndpointCmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
945	            EndpointCmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
946	            EndpointCmd::Access { action } => cmd_access(action),
947	            EndpointCmd::Description { action } => {
948	                cmd_description(action.unwrap_or(DescriptionCmd::Show { id: None }))
949	            }
950	            EndpointCmd::Role { id, overwrite } => cmd_role(id, overwrite),
951	            EndpointCmd::Run {
952	                adapter,
953	                id,
954	                resume,
955	                start,
956	                attach,
957	                view,
958	            } => cmd_endpoint_run(&adapter, &id, resume, start, attach, view),
959	        },

        // The endpoint noun (M8 decisions 1–2): bare = the merged list.
        // [impl->REQ-CLI-1]
        Cmd::Endpoint { action } => match action.unwrap_or(EndpointCmd::List {
            local: false,
            subnet: None,
            detail: false,
        }) {
            EndpointCmd::List {
                local,
                subnet,
                detail,
            } => cmd_endpoint_list(local, subnet, detail),
            EndpointCmd::Fork {
                src,
                new_id,
                subnet,
                delete_source,
            } => cmd_fork(&src, &new_id, &subnet, delete_source),
            EndpointCmd::Suspend { id } => {
                cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED")
            }
            EndpointCmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
            EndpointCmd::Shutdown { id } => cmd_shutdown(id),
            EndpointCmd::Stop { id } => cmd_stop(&id),
            EndpointCmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
            EndpointCmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
            EndpointCmd::Access { action } => cmd_access(action),
            EndpointCmd::Description { action } => {
                cmd_description(action.unwrap_or(DescriptionCmd::Show { id: None }))
            }
            EndpointCmd::Role { id, overwrite } => cmd_role(id, overwrite),
            EndpointCmd::Run {
                adapter,
                id,
                resume,
                start,
                attach,
                view,
            } => cmd_endpoint_run(&adapter, &id, resume, start, attach, view),
        },


Grep

pub fn resolve_option|pub fn list_|pub fn .*records|pub fn registered|pub fn all_adapters|fn load_record|adapter list|registered_records|read_dir.*adapters_dir|pub fn .*adapter


340:pub fn deregister(adapters_dir: &Path, name: &str) -> Result<Option<String>, RegistryError> {
351:pub fn load_record(adapters_dir: &Path, name: &str) -> Result<AdapterRecord, RegistryError> {
385:pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
386:    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
402:pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
462:pub fn local_profile_names(adapters_dir: &Path, adapter: &str) -> Vec<String> {
463:    let Ok(entries) = std::fs::read_dir(local_profiles_dir(adapters_dir, adapter)) else {
627:pub fn resolve_option(
652:pub fn resolve_option_in(
675:pub fn resolve_option_onto(


167:        action: AdapterCmd,
461:enum AdapterCmd {
968:        Cmd::Adapter { action } => cmd_adapter(action),
1090:                 harness adapter / valid profile on this node ({e}; spt adapter list)"
4557:fn cmd_adapter(action: AdapterCmd) -> i32 {
4592:        AdapterCmd::Add { path, github } => {
4673:        AdapterCmd::Remove { name, force } => match registry::deregister(&adapters, &name) {
4693:        AdapterCmd::List => {
4721:        AdapterCmd::CreateProfile {
4755:        AdapterCmd::DeleteProfile { adapter, name } => {
4768:        AdapterCmd::GetString { option, key } => match registry::get_string(&adapters, &option, &key)
4790:        AdapterCmd::DigestProof { option, sample } => {
4791:            cmd_adapter_digest_proof(&adapters, &option, sample.as_deref())
4794:        AdapterCmd::SetString { option, key, value } => {
4824:fn cmd_adapter_digest_proof(
5031:                         kind=\"shell\" adapter / valid profile on this node ({e}; spt adapter list)"
5039:                     kind=\"shell\" adapter on this node (spt adapter list)"
6470:                action: AdapterCmd::Add {
6481:                action: AdapterCmd::Add {
6492:                action: AdapterCmd::Remove { name, force },

[Showing results with pagination = limit: 20]


4693	        AdapterCmd::List => {
4694	            let records = registry::all_records(&adapters);
4695	            if records.is_empty() {
4696	                println!("no adapters registered");
4697	            }
4698	            for r in records {
4699	                let state = if r.active { "active" } else { "deregistered" };
4700	                println!(
4701	                    "{}: {:?} {:?} {state} (from {})",
4702	                    r.name, r.kind, r.mode, r.source_dir
4703	                );
4704	                // Profiles render adjacent as composite options — shipped (from
4705	                // the parent manifest) then local (overlay files beside it).
4706	                // [impl->REQ-MANIFEST-2]
4707	                if let Ok(m) = registry::load_manifest(&adapters, &r) {
4708	                    let mut shipped: Vec<&String> = m.profiles.keys().collect();
4709	                    shipped.sort();
4710	                    for p in shipped {
4711	                        println!("  {}:{p} (shipped)", r.name);
4712	                    }
4713	                }
4714	                for p in registry::local_profile_names(&adapters, &r.name) {
4715	                    println!("  {}:{p} (local)", r.name);
4716	                }
4717	            }
4718	            0
4719	        }
4720	        // [impl->REQ-MANIFEST-2]
4721	        AdapterCmd::CreateProfile {
4722	            adapter,

        AdapterCmd::List => {
            let records = registry::all_records(&adapters);
            if records.is_empty() {
                println!("no adapters registered");
            }
            for r in records {
                let state = if r.active { "active" } else { "deregistered" };
                println!(
                    "{}: {:?} {:?} {state} (from {})",
                    r.name, r.kind, r.mode, r.source_dir
                );
                // Profiles render adjacent as composite options — shipped (from
                // the parent manifest) then local (overlay files beside it).
                // [impl->REQ-MANIFEST-2]
                if let Ok(m) = registry::load_manifest(&adapters, &r) {
                    let mut shipped: Vec<&String> = m.profiles.keys().collect();
                    shipped.sort();
                    for p in shipped {
                        println!("  {}:{p} (shipped)", r.name);
                    }
                }
                for p in registry::local_profile_names(&adapters, &r.name) {
                    println!("  {}:{p} (local)", r.name);
                }
            }
            0
        }
        // [impl->REQ-MANIFEST-2]
        AdapterCmd::CreateProfile {
            adapter,


385	pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
386	    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
387	        return Vec::new();
388	    };
389	    let mut out: Vec<AdapterRecord> = entries
390	        .filter_map(|e| e.ok())
391	        .filter_map(|e| load_record(adapters_dir, &e.file_name().to_string_lossy()).ok())
392	        .collect();
393	    out.sort_by(|a, b| a.name.cmp(&b.name));
394	    out
395	}
396
397	/// The **active** registered set with manifests resolved — the single source
398	/// for creation-time adapter selection, shell discovery, and the R-UPD-5
399	/// ripple. A pointer-mode record whose source tree has vanished is skipped
400	/// (tolerant), not fatal.
401	// [impl->REQ-INSTALL-4]
402	pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
403	    all_records(adapters_dir)
404	        .into_iter()
405	        .filter(|r| r.active)
406	        .filter_map(|r| load_manifest(adapters_dir, &r).ok().map(|m| (r, m)))
407	        .collect()
408	}
409

pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
        return Vec::new();
    };
    let mut out: Vec<AdapterRecord> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| load_record(adapters_dir, &e.file_name().to_string_lossy()).ok())
        .collect();
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

/// The **active** registered set with manifests resolved — the single source
/// for creation-time adapter selection, shell discovery, and the R-UPD-5
/// ripple. A pointer-mode record whose source tree has vanished is skipped
/// (tolerant), not fatal.
// [impl->REQ-INSTALL-4]
pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
    all_records(adapters_dir)
        .into_iter()
        .filter(|r| r.active)
        .filter_map(|r| load_manifest(adapters_dir, &r).ok().map(|m| (r, m)))
        .collect()
}


1	//! `spt rc <id>` — the operator-side attach pump (M12-W1, REQ-RC-1): attach a
2	//! local terminal to a broker-held PTY session.
3	//!
4	//! **One pump, swappable transport** (the doyle invariant). The attach protocol
5	//! is `serve_attach` on the target side ⇄ this pump on the operator side, both
6	//! speaking `AttachRecord` over a broker net-stream. *Local* attach is the
7	//! degenerate single-node case: the operator dials its OWN node (loopback peer)
8	//! and the broker's inbound-stream dispatch (`dispatch.rs`) runs `serve_attach`
9	//! against the same broker — the exact cross-node accept+serve path, no local
10	//! special-case branch. The one-path invariant lives at this pump, not the
11	//! transport under the stream.
12	//!
13	//! **Detach never kills the session.** The broker owns the PTY; closing our send
14	//! side ends the viewport with the session still running (`serve_attach` →
15	//! `Detached`). PTY ownership stays with the broker (the KNOWN-HAZARDS
16	//! attach-lifecycle invariant).
17	//!
18	//! **Single-Brain pump.** A `Brain` is `&mut`-single-threaded, so the main thread
19	//! owns it: a stdin reader thread only *produces* raw bytes onto an mpsc channel
20	//! (and watches for the detach keybind); the main loop interleaves draining that
21	//! channel into `send_attach_input` with `read_event_until` for inbound `Output`.
22	// [impl->REQ-RC-1]
23
24	use std::io::{Read, Write};
25	use std::sync::atomic::{AtomicU64, Ordering};
26	use std::sync::mpsc;
27	use std::time::{Duration, Instant};
28
29	use spt_daemon::attach::{request_attach, send_attach_input};
30	use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
31	use spt_daemon::msg::decode_bytes;
32	use spt_net::net::attach::{AttachDecoder, AttachRecord};
33
34	/// The detach prefix: ctrl-b (0x02), matching the legacy capsule's prefix so the
35	/// muscle memory carries over. `ctrl-b d` detaches; `ctrl-b ctrl-b` sends one
36	/// literal ctrl-b to the session; any other byte after the prefix is sent
37	/// through verbatim (prefix + byte). Documented in the CLI help.
38	const DETACH_PREFIX: u8 = 0x02; // ctrl-b
39	const DETACH_KEY: u8 = b'd';
40
41	/// How long the main loop blocks for an inbound event before looping back to
42	/// drain stdin — the full-duplex interleave granularity.
43	const POLL_SLICE: Duration = Duration::from_millis(40);
44
45	/// A message from the stdin reader thread to the pump.
46	enum StdinMsg {
47	    /// Raw bytes to forward to the session as `AttachRecord::Input`.
48	    Bytes(Vec<u8>),
49	    /// The operator hit the detach keybind — end the viewport, leave the session.
50	    Detach,
51	}
52
53	/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
54	/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
55	/// exactly-once at the PTY — a reused id from a prior session would be deduped,
56	/// dropping the keystroke). Random base + monotonic counter.
57	struct OpMinter(AtomicU64);
58	impl OpMinter {
59	    fn new() -> Self {
60	        let seed = spt_proto::identity::Identity::generate().seed();

//! `spt rc <id>` — the operator-side attach pump (M12-W1, REQ-RC-1): attach a
//! local terminal to a broker-held PTY session.
//!
//! **One pump, swappable transport** (the doyle invariant). The attach protocol
//! is `serve_attach` on the target side ⇄ this pump on the operator side, both
//! speaking `AttachRecord` over a broker net-stream. *Local* attach is the
//! degenerate single-node case: the operator dials its OWN node (loopback peer)
//! and the broker's inbound-stream dispatch (`dispatch.rs`) runs `serve_attach`
//! against the same broker — the exact cross-node accept+serve path, no local
//! special-case branch. The one-path invariant lives at this pump, not the
//! transport under the stream.
//!
//! **Detach never kills the session.** The broker owns the PTY; closing our send
//! side ends the viewport with the session still running (`serve_attach` →
//! `Detached`). PTY ownership stays with the broker (the KNOWN-HAZARDS
//! attach-lifecycle invariant).
//!
//! **Single-Brain pump.** A `Brain` is `&mut`-single-threaded, so the main thread
//! owns it: a stdin reader thread only *produces* raw bytes onto an mpsc channel
//! (and watches for the detach keybind); the main loop interleaves draining that
//! channel into `send_attach_input` with `read_event_until` for inbound `Output`.
// [impl->REQ-RC-1]

use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};

use spt_daemon::attach::{request_attach, send_attach_input};
use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
use spt_daemon::msg::decode_bytes;
use spt_net::net::attach::{AttachDecoder, AttachRecord};

/// The detach prefix: ctrl-b (0x02), matching the legacy capsule's prefix so the
/// muscle memory carries over. `ctrl-b d` detaches; `ctrl-b ctrl-b` sends one
/// literal ctrl-b to the session; any other byte after the prefix is sent
/// through verbatim (prefix + byte). Documented in the CLI help.
const DETACH_PREFIX: u8 = 0x02; // ctrl-b
const DETACH_KEY: u8 = b'd';

/// How long the main loop blocks for an inbound event before looping back to
/// drain stdin — the full-duplex interleave granularity.
const POLL_SLICE: Duration = Duration::from_millis(40);

/// A message from the stdin reader thread to the pump.
enum StdinMsg {
    /// Raw bytes to forward to the session as `AttachRecord::Input`.
    Bytes(Vec<u8>),
    /// The operator hit the detach keybind — end the viewport, leave the session.
    Detach,
}

/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
/// exactly-once at the PTY — a reused id from a prior session would be deduped,
/// dropping the keystroke). Random base + monotonic counter.
struct OpMinter(AtomicU64);
impl OpMinter {
    fn new() -> Self {
        let seed = spt_proto::identity::Identity::generate().seed();


Have all surfaces. `run_attach(id, view)`, `registered`/`all_records`, profiles tree, ledger. Set tasks + check dep availability.


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && echo "--- ratatui in lock ---" && grep -m1 '^name = "ratatui"' Cargo.lock; grep -m1 '^name = "nucleo-matcher"' Cargo.lock; echo "--- crossterm version ---" && grep -A1 '^name = "crossterm"' Cargo.lock | grep version; echo "--- workspace members ---" && grep -A30 '^\[workspace\]' Cargo.toml | grep -E 'members|spt' | head -20


--- ratatui in lock ---
--- crossterm version ---
version = "0.28.1"
--- workspace members ---
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock", "crates/xtask"]
# — the product version (v0.1.0 tag, `spt --version`). The wire-protocol


Neither ratatui nor nucleo in lock — both new. crossterm 0.28.1 present (ratatui 0.29 pairs with it).

Now: update plan with crystallized design, add REQs, then build. Setting up tasks.


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


TaskCreate

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.


Task #1 created successfully: Update M12-W2-PLAN.md with crystallized post-ruling design


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).


Task #2 created successfully: Add REQ-RUN-PICKER + REQ-RUN-SHORTCUT to traceable-reqs.toml


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.


Task #3 created successfully: Add ratatui + nucleo-matcher deps; make Run{} adapter/id optional + add --create


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.


Task #4 created successfully: Build picker state model (pure, testable)


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.


Task #5 created successfully: Build picker data gather over existing surfaces


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.


Task #6 created successfully: Build picker ratatui view (Buffer-testable)


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.


Task #7 created successfully: Build cc-<id> shortcut generation (REQ-RUN-SHORTCUT)


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).


Task #8 created successfully: Wire picker live loop + terminal-action dispatch (single bringup path)


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).


Task #9 created successfully: Tag evidence, full suite + clippy -D + traceable check, ping doyle to gate


Tasks set. Task 1 — append crystallized design to plan.


Edit

## Notes
- The picker is a pure front-end: it MUST NOT introduce a second bringup path — every

## Crystallized design (2026-06-14, post-ruling — doyle GO, `M12-W2-RULING.md`)
doyle verified surfaces vs source before ruling. Rulings folded in:
- **Q1 attached-state (blue ■ + Kick): DEFERRED** (option a). W2 ships **online/offline
  only**. The blue tri-state + "Kick <node>" need a new broker attach-presence query AND a
  force-detach wire op — a distinct broker mutation in the PTY-ownership hazard zone → its
  own focused slice + gate, NOT a picker-wave rider. (`presence.rs` has
  snapshots/most_recently_active/resolve but no "which node is attached to E's PTY".)
- **Q2 project history: best-effort, no new store.** Derive newest→oldest from the existing
  contextstore `p-<project>` branches; render empty if none. Informational pane,
  degrade-to-empty.
- **Q3 Resume-from-history: BUILD IT** (correction — my TODO#3 was un-run; the surface
  EXISTS). `spt-store/src/sessions.rs::last_k(perch, k) -> Vec<SessionEntry{ts RFC3339-UTC,
  session_id, trigger}>` (M10 / ADR-0019 / REQ-TERM-6, bounded MAX_LEDGER=64, garbage-tolerant)
  IS the per-endpoint session log. `--resume <session>` already on `Run{}`. The picker
  enumerates `last_k` for the **offline LOCAL** endpoint, renders `<project> @ <ts> (…id5)`
  (project best-effort; the ledger row carries ts/session_id/trigger, not a project — title
  falls back to the trigger token when no project association), feeds `session_id` →
  `cmd_endpoint_run` with `resume=Some(id)`. NO new daemon surface. Offline+LOCAL only (a
  remote offline endpoint = Instantiate-locally). Empty ledger ⇒ no Resume option. Adapter
  resume-capability is its manifest `resume`'s job, surfaced at the terminal action.
- **Q4 `cc-<id>` shortcut: per-OS native + sentinel-guarded overwrite.** Current-OS only:
  `.ps1` on Windows, POSIX `sh` (+`chmod +x`) on Unix. Bake the non-interactive flags.
  Overwrite = the generator writes + checks a generated-by **sentinel** header marker:
  overwrite our-own freely, **REFUSE + warn** if a same-named file lacks the sentinel (never
  clobber a user file). "Update if exists" = overwrite-our-own-only.
- **Q5 flags: additive `--create` approved** (N-1-safe; the default-fresh made explicit).
  Shortcut covers **terminal actions only**: adapter[:profile] + id + (create|resume) +
  (start|attach|view). Resume IS bakeable. Kick/Instantiate/Change-adapter/Fork stay
  picker-only (not bakeable).
- **REQs: REQ-RUN-PICKER + REQ-RUN-SHORTCUT** (2-REQ granularity approved). **Stages doc +
  impl + unit; NO `int` on the TUI** — ratatui renders to a testable `Buffer`: assert the
  rendered buffer + state-model transitions (category nav, filter narrowing, cursor
  windowing, status→square, adapter→profile tree, ledger→resume-titles, shortcut content +
  create-vs-update, flag→action) as unit. The live key loop is a **manual-verify leg** (like
  REQ-PAIR-6's OS probe). The integrating bringup path is ALREADY int-covered (W1/W1.5
  `cmd_endpoint_run`); the picker rides it.
- **Q6/Q7 UX defaults: APPROVED.** In-process `spt/src/picker/`; bare `spt endpoint run` (no
  `--adapter`/`--id`) = interactive, flags-present = today's non-interactive path untouched;
  action enum = single source of truth (tap-mode layers on later); nucleo `/` filter;
  ratatui two-column layout; pinned legend.

### Verified source surfaces (build directly on these)
- `registry::registered(adapters_dir) -> Vec<(AdapterRecord, Manifest)>` + `all_records`;
  `manifest.profiles.keys()` = shipped profiles; `registry::local_profile_names` = locals;
  `AdapterKind::Harness` gate. (L2 adapter→profile tree.)
- `roster::enumerate()` / `detect_self_id`; `wansend::load_snapshots(identity/registry)` +
  `resource_projection(reg, hidden_fn)` (`endpoint_id, node, status, resources`);
  `VisibilityStore` + `SubnetStore`. (Pick-existing spine + status.)
- `spt_store::info::read_info(perch).resources` (endpoint description);
  `perch::resolve_perch_path(id, ParentHint::Infer)` → `sessions::last_k(perch, k)` (resume).
- `rc::run_attach(id, view)` (attach/view); `cmd_endpoint_run` (start); `cmd_fork` (Fork).
- crossterm 0.28.1 already in tree (W1 rc raw-mode); ratatui + nucleo-matcher = new deps.

### Binding gate invariant (doyle gates on this)
**No second bringup path** — every terminal action routes through `cmd_endpoint_run` /
existing CLI fns (`cmd_fork`, wake, rc pump). One bringup core, same discipline as W1.5's
one-pump.

## Notes
- The picker is a pure front-end: it MUST NOT introduce a second bringup path — every


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

## Crystallized design (2026-06-14, post-ruling — doyle GO, `M12-W2-RULING.md`)
doyle verified surfaces vs source before ruling. Rulings folded in:
- **Q1 attached-state (blue ■ + Kick): DEFERRED** (option a). W2 ships **online/offline
  only**. The blue tri-state + "Kick <node>" need a new broker attach-presence query AND a
  force-detach wire op — a distinct broker mutation in the PTY-ownership hazard zone → its
  own focused slice + gate, NOT a picker-wave rider. (`presence.rs` has
  snapshots/most_recently_active/resolve but no "which node is attached to E's PTY".)
- **Q2 project history: best-effort, no new store.** Derive newest→oldest from the existing
  contextstore `p-<project>` branches; render empty if none. Informational pane,
  degrade-to-empty.
- **Q3 Resume-from-history: BUILD IT** (correction — my TODO#3 was un-run; the surface
  EXISTS). `spt-store/src/sessions.rs::last_k(perch, k) -> Vec<SessionEntry{ts RFC3339-UTC,
  session_id, trigger}>` (M10 / ADR-0019 / REQ-TERM-6, bounded MAX_LEDGER=64, garbage-tolerant)
  IS the per-endpoint session log. `--resume <session>` already on `Run{}`. The picker
  enumerates `last_k` for the **offline LOCAL** endpoint, renders `<project> @ <ts> (…id5)`
  (project best-effort; the ledger row carries ts/session_id/trigger, not a project — title
  falls back to the trigger token when no project association), feeds `session_id` →
  `cmd_endpoint_run` with `resume=Some(id)`. NO new daemon surface. Offline+LOCAL only (a
  remote offline endpoint = Instantiate-locally). Empty ledger ⇒ no Resume option. Adapter
  resume-capability is its manifest `resume`'s job, surfaced at the terminal action.
- **Q4 `cc-<id>` shortcut: per-OS native + sentinel-guarded overwrite.** Current-OS only:
  `.ps1` on Windows, POSIX `sh` (+`chmod +x`) on Unix. Bake the non-interactive flags.
  Overwrite = the generator writes + checks a generated-by **sentinel** header marker:
  overwrite our-own freely, **REFUSE + warn** if a same-named file lacks the sentinel (never
  clobber a user file). "Update if exists" = overwrite-our-own-only.
- **Q5 flags: additive `--create` approved** (N-1-safe; the default-fresh made explicit).
  Shortcut covers **terminal actions only**: adapter[:profile] + id + (create|resume) +
  (start|attach|view). Resume IS bakeable. Kick/Instantiate/Change-adapter/Fork stay
  picker-only (not bakeable).
- **REQs: REQ-RUN-PICKER + REQ-RUN-SHORTCUT** (2-REQ granularity approved). **Stages doc +
  impl + unit; NO `int` on the TUI** — ratatui renders to a testable `Buffer`: assert the
  rendered buffer + state-model transitions (category nav, filter narrowing, cursor
  windowing, status→square, adapter→profile tree, ledger→resume-titles, shortcut content +
  create-vs-update, flag→action) as unit. The live key loop is a **manual-verify leg** (like
  REQ-PAIR-6's OS probe). The integrating bringup path is ALREADY int-covered (W1/W1.5
  `cmd_endpoint_run`); the picker rides it.
- **Q6/Q7 UX defaults: APPROVED.** In-process `spt/src/picker/`; bare `spt endpoint run` (no
  `--adapter`/`--id`) = interactive, flags-present = today's non-interactive path untouched;
  action enum = single source of truth (tap-mode layers on later); nucleo `/` filter;
  ratatui two-column layout; pinned legend.

### Verified source surfaces (build directly on these)
- `registry::registered(adapters_dir) -> Vec<(AdapterRecord, Manifest)>` + `all_records`;
  `manifest.profiles.keys()` = shipped profiles; `registry::local_profile_names` = locals;
  `AdapterKind::Harness` gate. (L2 adapter→profile tree.)
- `roster::enumerate()` / `detect_self_id`; `wansend::load_snapshots(identity/registry)` +
  `resource_projection(reg, hidden_fn)` (`endpoint_id, node, status, resources`);
  `VisibilityStore` + `SubnetStore`. (Pick-existing spine + status.)
- `spt_store::info::read_info(perch).resources` (endpoint description);
  `perch::resolve_perch_path(id, ParentHint::Infer)` → `sessions::last_k(perch, k)` (resume).
- `rc::run_attach(id, view)` (attach/view); `cmd_endpoint_run` (start); `cmd_fork` (Fork).
- crossterm 0.28.1 already in tree (W1 rc raw-mode); ratatui + nucleo-matcher = new deps.

### Binding gate invariant (doyle gates on this)
**No second bringup path** — every terminal action routes through `cmd_endpoint_run` /
existing CLI fns (`cmd_fork`, wake, rc pump). One bringup core, same discipline as W1.5's
one-pump.

## Notes
- The picker is a pure front-end: it MUST NOT introduce a second bringup path — every

create-vs-update, the flag→action mapping. Activate stages as evidence lands. Full suite +

clippy -D + `traceable-reqs check` EXIT=0. W2 gate (doyle).

+## Crystallized design (2026-06-14, post-ruling — doyle GO, `M12-W2-RULING.md`)

+doyle verified surfaces vs source before ruling. Rulings folded in:

+- **Q1 attached-state (blue ■ + Kick): DEFERRED** (option a). W2 ships **online/offline

+  only**. The blue tri-state + "Kick <node>" need a new broker attach-presence query AND a

+  force-detach wire op — a distinct broker mutation in the PTY-ownership hazard zone → its

+  own focused slice + gate, NOT a picker-wave rider. (`presence.rs` has

+  snapshots/most_recently_active/resolve but no "which node is attached to E's PTY".)

+- **Q2 project history: best-effort, no new store.** Derive newest→oldest from the existing

+  contextstore `p-<project>` branches; render empty if none. Informational pane,

+- **Q3 Resume-from-history: BUILD IT** (correction — my TODO#3 was un-run; the surface

+  EXISTS). `spt-store/src/sessions.rs::last_k(perch, k) -> Vec<SessionEntry{ts RFC3339-UTC,

+  session_id, trigger}>` (M10 / ADR-0019 / REQ-TERM-6, bounded MAX_LEDGER=64, garbage-tolerant)

+  IS the per-endpoint session log. `--resume <session>` already on `Run{}`. The picker

+  enumerates `last_k` for the **offline LOCAL** endpoint, renders `<project> @ <ts> (…id5)`

+  (project best-effort; the ledger row carries ts/session_id/trigger, not a project — title

+  falls back to the trigger token when no project association), feeds `session_id` →

+  `cmd_endpoint_run` with `resume=Some(id)`. NO new daemon surface. Offline+LOCAL only (a

+  remote offline endpoint = Instantiate-locally). Empty ledger ⇒ no Resume option. Adapter

+  resume-capability is its manifest `resume`'s job, surfaced at the terminal action.

+- **Q4 `cc-<id>` shortcut: per-OS native + sentinel-guarded overwrite.** Current-OS only:

+  `.ps1` on Windows, POSIX `sh` (+`chmod +x`) on Unix. Bake the non-interactive flags.

+  Overwrite = the generator writes + checks a generated-by **sentinel** header marker:

+  overwrite our-own freely, **REFUSE + warn** if a same-named file lacks the sentinel (never

+  clobber a user file). "Update if exists" = overwrite-our-own-only.

+- **Q5 flags: additive `--create` approved** (N-1-safe; the default-fresh made explicit).

+  Shortcut covers **terminal actions only**: adapter[:profile] + id + (create|resume) +

+- **REQs: REQ-RUN-PICKER + REQ-RUN-SHORTCUT** (2-REQ granularity approved). **Stages doc +

+  rendered buffer + state-model transitions (category nav, filter narrowing, cursor

+  windowing, status→square, adapter→profile tree, ledger→resume-titles, shortcut content +

+  REQ-PAIR-6's OS probe). The integrating bringup path is ALREADY int-covered (W1/W1.5

+  `cmd_endpoint_run`); the picker rides it.

+- **Q6/Q7 UX defaults: APPROVED.** In-process `spt/src/picker/`; bare `spt endpoint run` (no

+  `--adapter`/`--id`) = interactive, flags-present = today's non-interactive path untouched;

+  action enum = single source of truth (tap-mode layers on later); nucleo `/` filter;

+  ratatui two-column layout; pinned legend.

+### Verified source surfaces (build directly on these)

+- `registry::registered(adapters_dir) -> Vec<(AdapterRecord, Manifest)>` + `all_records`;

+  `manifest.profiles.keys()` = shipped profiles; `registry::local_profile_names` = locals;

+  `AdapterKind::Harness` gate. (L2 adapter→profile tree.)

+- `roster::enumerate()` / `detect_self_id`; `wansend::load_snapshots(identity/registry)` +

+  `VisibilityStore` + `SubnetStore`. (Pick-existing spine + status.)

+- `spt_store::info::read_info(perch).resources` (endpoint description);

+  `perch::resolve_perch_path(id, ParentHint::Infer)` → `sessions::last_k(perch, k)` (resume).

+- `rc::run_attach(id, view)` (attach/view); `cmd_endpoint_run` (start); `cmd_fork` (Fork).

+- crossterm 0.28.1 already in tree (W1 rc raw-mode); ratatui + nucleo-matcher = new deps.

+### Binding gate invariant (doyle gates on this)

+**No second bringup path** — every terminal action routes through `cmd_endpoint_run` /

+existing CLI fns (`cmd_fork`, wake, rc pump). One bringup core, same discipline as W1.5's

terminal action routes through `cmd_endpoint_run` / the existing CLI fns. One bringup core,


Task 1 done. Task 2 — add REQs. Read insertion point (after REQ-HOST-RUN-2).


450	# ── M12 spt-hosted harness bringup + user PTY attach (the spt-claude-code gating
451	#    prerequisite; M12-PLAN.md Wave 1). Registered now per rule 5; stages activate
452	#    incrementally as evidence lands (doc+impl+unit this wave, int at the W-final E2E). ──
453	[[requirements]]
454	id = "REQ-HOST-RUN-1"
455	title = "spt-hosted harness bringup: `spt endpoint run` spawns an adapter's `[session.self]` command template into a broker-held PTY (the spawn-session seam, brain.rs spawn_session_pid — same broker path shellhost.rs launch_shell_brokered_in uses for shells, now for kind=\"harness\" self-role), registers the perch under the given endpoint id, returns the id. Reverses today's harness-hosted-only launch (external launcher → `api bind`). Non-interactive flag set (--adapter <a[:profile]> --id <id> --create --resume <session> --attach|--start|--view) covers every terminal action of the W2 interactive picker so shortcuts (cc-<id>) bake fully non-interactive launches; composite adapter:profile resolves via registry::resolve_option leaf-replace overlay."
456	required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = harnesshost::{prepare_harness_spawn, launch_harness_brokered_in} + cli cmd_endpoint_run; unit = harnesshost template-fill/fail-closed/mint; int = spt_hosted_bringup_then_cross_node_attach_drives_the_pty (bringup spawns into the broker PTY, cross-node attach drives it). cwd/project deferred to REQ-HOST-RUN-2 (W1 ships broker-inherited cwd)
457	[[requirements]]
458	id = "REQ-RC-1"
459	title = "`spt rc <id>` — user CLI attaching a local terminal to a broker-held PTY, reusing the cross-node attach machinery (attach.rs request_attach → send_attach_input pump, spt-net AttachRecord codec); local attach is the degenerate single-node case of the cross-node path (rides REQ-TERM-3 byte-stream streaming). Read-only `--view` (watch, no stdin forwarded). Clean detach that does NOT terminate the broker-held session (KNOWN-HAZARDS: PTY ownership stays with the broker; no termination on detach). Explicit detach keybind that cannot collide with harness passthrough input (legacy capsule used a ctrl-b prefix); documented. ConPTY DSR auto-answer in the attach reader (hazard 5.5)."
460	required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = rc::run_attach pump (single-Brain + stdin-thread + seq-dedup + ctrl-b detach) + cli Cmd::Rc; unit = detach-keybind semantics + prefix-spans-chunks + op-minter; int (CROSS-NODE face) = spt_hosted_bringup_then_cross_node_attach_drives_the_pty + loopback_self_dial_is_refused (pins the local-transport choice). LOCAL face int (loopback-conn B1) lands W1.5 with the "both transports → one pump" assertion (doyle 2026-06-14)
461	[[requirements]]
462	id = "REQ-HOST-RUN-2"
463	title = "Project-scoped working directory for spt-hosted bringup: `spt endpoint run` lands the broker-spawned harness PTY in the user's PROJECT cwd, not the daemon's, via an additive `SpawnReq.cwd` field carried through the broker PTY spawn (portable-pty CommandBuilder cwd). N-1-safe wire change (additive, defaulted). Required because the consumer (Claude Code) is project-scoped: broker-inherited cwd = the daemon's cwd = the wrong `.claude`, wrong session history, wrong digest source; `cc <id>` at a project root MUST land the harness in that project. W1 ships broker-inherited cwd as a bringup-proof shortcut only; this REQ must land before the M12 gate (doyle, 2026-06-14)."
464	required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)
465
466	# ───────────────────────────── Install / migration / infra ──────────────────
467	[[requirements]]
468	id = "REQ-INSTALL-1"
469	title = "Two install paths; signed one-line script; OS-service registration"
470	required_stages = ["doc", "impl", "int"]  # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3)
471	[[requirements]]
472	id = "REQ-INSTALL-2"
473	title = "Marketplace-repackaging-friendly install"
474	required_stages = ["doc"]  # doc activated M6-D2: the relocatable-binary + minimal non-OS-entangled install-logic stance documented (CONTEXT §Installation tag) — the scripts are that stance (single static binary, no OS service, user-scope PATH only); actual marketplace repackaging stays doc-stage per M6-PLAN §Out
475	[[requirements]]
476	id = "REQ-INSTALL-3"
477	title = "Idempotent + interactive-optional first run"
478	required_stages = ["impl", "int"]  # activated M6-D2: idempotent re-run is construction — binary replaced atomically, PATH registration added at most once (marker-guarded .profile block / HKCU entry-set check); interactive-optional = the scripts never prompt at all (REQ-INSTALL-5's stronger non-interactive contract subsumes it; first-run identity gen + daemon start were already unattended). int = oneliner_e2e re-run rung (second run green, single PATH block)
479	[[requirements]]

# ── M12 spt-hosted harness bringup + user PTY attach (the spt-claude-code gating
#    prerequisite; M12-PLAN.md Wave 1). Registered now per rule 5; stages activate
#    incrementally as evidence lands (doc+impl+unit this wave, int at the W-final E2E). ──
[[requirements]]
id = "REQ-HOST-RUN-1"
title = "spt-hosted harness bringup: `spt endpoint run` spawns an adapter's `[session.self]` command template into a broker-held PTY (the spawn-session seam, brain.rs spawn_session_pid — same broker path shellhost.rs launch_shell_brokered_in uses for shells, now for kind=\"harness\" self-role), registers the perch under the given endpoint id, returns the id. Reverses today's harness-hosted-only launch (external launcher → `api bind`). Non-interactive flag set (--adapter <a[:profile]> --id <id> --create --resume <session> --attach|--start|--view) covers every terminal action of the W2 interactive picker so shortcuts (cc-<id>) bake fully non-interactive launches; composite adapter:profile resolves via registry::resolve_option leaf-replace overlay."
required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = harnesshost::{prepare_harness_spawn, launch_harness_brokered_in} + cli cmd_endpoint_run; unit = harnesshost template-fill/fail-closed/mint; int = spt_hosted_bringup_then_cross_node_attach_drives_the_pty (bringup spawns into the broker PTY, cross-node attach drives it). cwd/project deferred to REQ-HOST-RUN-2 (W1 ships broker-inherited cwd)
[[requirements]]
id = "REQ-RC-1"
title = "`spt rc <id>` — user CLI attaching a local terminal to a broker-held PTY, reusing the cross-node attach machinery (attach.rs request_attach → send_attach_input pump, spt-net AttachRecord codec); local attach is the degenerate single-node case of the cross-node path (rides REQ-TERM-3 byte-stream streaming). Read-only `--view` (watch, no stdin forwarded). Clean detach that does NOT terminate the broker-held session (KNOWN-HAZARDS: PTY ownership stays with the broker; no termination on detach). Explicit detach keybind that cannot collide with harness passthrough input (legacy capsule used a ctrl-b prefix); documented. ConPTY DSR auto-answer in the attach reader (hazard 5.5)."
required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = rc::run_attach pump (single-Brain + stdin-thread + seq-dedup + ctrl-b detach) + cli Cmd::Rc; unit = detach-keybind semantics + prefix-spans-chunks + op-minter; int (CROSS-NODE face) = spt_hosted_bringup_then_cross_node_attach_drives_the_pty + loopback_self_dial_is_refused (pins the local-transport choice). LOCAL face int (loopback-conn B1) lands W1.5 with the "both transports → one pump" assertion (doyle 2026-06-14)
[[requirements]]
id = "REQ-HOST-RUN-2"
title = "Project-scoped working directory for spt-hosted bringup: `spt endpoint run` lands the broker-spawned harness PTY in the user's PROJECT cwd, not the daemon's, via an additive `SpawnReq.cwd` field carried through the broker PTY spawn (portable-pty CommandBuilder cwd). N-1-safe wire change (additive, defaulted). Required because the consumer (Claude Code) is project-scoped: broker-inherited cwd = the daemon's cwd = the wrong `.claude`, wrong session history, wrong digest source; `cc <id>` at a project root MUST land the harness in that project. W1 ships broker-inherited cwd as a bringup-proof shortcut only; this REQ must land before the M12 gate (doyle, 2026-06-14)."
required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)

# ───────────────────────────── Install / migration / infra ──────────────────
[[requirements]]
id = "REQ-INSTALL-1"
title = "Two install paths; signed one-line script; OS-service registration"
required_stages = ["doc", "impl", "int"]  # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3)
[[requirements]]
id = "REQ-INSTALL-2"
title = "Marketplace-repackaging-friendly install"
required_stages = ["doc"]  # doc activated M6-D2: the relocatable-binary + minimal non-OS-entangled install-logic stance documented (CONTEXT §Installation tag) — the scripts are that stance (single static binary, no OS service, user-scope PATH only); actual marketplace repackaging stays doc-stage per M6-PLAN §Out
[[requirements]]
id = "REQ-INSTALL-3"
title = "Idempotent + interactive-optional first run"
required_stages = ["impl", "int"]  # activated M6-D2: idempotent re-run is construction — binary replaced atomically, PATH registration added at most once (marker-guarded .profile block / HKCU entry-set check); interactive-optional = the scripts never prompt at all (REQ-INSTALL-5's stronger non-interactive contract subsumes it; first-run identity gen + daemon start were already unattended). int = oneliner_e2e re-run rung (second run green, single PATH block)
[[requirements]]


required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)

# ───────────────────────────── Install / migration / infra ──────────────────

required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)
# ── M12 Wave 2: interactive `spt endpoint run` picker (M12-PLAN.md Wave 2 / M12-ENDPOINT-RUN-PICKER.md;
#    doyle ruling M12-W2-RULING.md). Pure front-end over existing surfaces — no second bringup path
#    (the gate invariant). doc+impl+unit; NO int — the TUI renders to a testable ratatui Buffer
#    (buffer + state-model asserts as unit) and the live key loop is a manual-verify leg (REQ-PAIR-6
#    precedent); the integrating bringup path is already int-covered by REQ-HOST-RUN-1/REQ-RC-1. ──
[[requirements]]
id = "REQ-RUN-PICKER"
title = "Interactive `spt endpoint run` picker (ratatui TUI): bare `spt endpoint run` (no --adapter/--id) enters an in-process picker (flags-present = the REQ-HOST-RUN-1 non-interactive path, untouched). Layer 1 picks kind (Create new | Pick existing). Create-new: choose a registered kind=\"harness\" adapter with its shipped+local profiles tree-nested (registry::registered / manifest.profiles / local_profile_names) → enter a charset-validated id → start. Pick-existing: category select (left/right) over [<cwd-project> | Local node | Subnet], endpoints grouped + alphabetically sorted per category, a status square per endpoint (online green ■ / offline gray ▢ — the blue \"attached\" tri-state + Kick are DEFERRED to a broker attach-presence slice, M12-W2-RULING Q1), type-to-filter (`/`, nucleo-matcher), a pinned keybind legend, and a right-half two-pane description (harness adapter:profile · best-effort project history newest→oldest from the contextstore p-<project> branches, empty-if-none · `spt endpoint description`). Confirm layer offers status-dependent options — Attach/Start/View (rc pump / cmd_endpoint_run) · Instantiate-locally (remote) · Change-harness-adapter (offline) · Fork (cmd_fork) · Resume-from-history (offline+LOCAL only; enumerate spt_store::sessions::last_k, titles `<project> @ <ts> (…id5)`, feed session_id → cmd_endpoint_run --resume). A single action enum is the source of truth so a future tap-mode (phone PTY) layers on without re-coupling to keybinds. EVERY terminal action routes through cmd_endpoint_run / existing CLI fns — no second bringup path."
required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: TUI Buffer + state-model unit asserts; live loop manual-verify; bringup path int-covered by REQ-HOST-RUN-1/REQ-RC-1)
[[requirements]]
id = "REQ-RUN-SHORTCUT"
title = "`cc-<id>` launcher shortcut generation (picker `s` keybind, M12-W2-T2.4): from any pre-start options set the picker writes/updates a `cc-<id>` launcher at the project root baking the current selection's non-interactive `spt endpoint run` flags (terminal actions only: adapter[:profile] + id + (create|resume) + (start|attach|view); Kick/Instantiate/Change-adapter/Fork are interactive-only, not bakeable). The script is the CURRENT OS's native form — `.ps1` on Windows, POSIX `sh` (+chmod +x) on Unix (a single portable form can't be both). Overwrite is SENTINEL-guarded: the generator writes + checks a generated-by header marker — it overwrites its own prior output freely (\"Update `cc-<id>` shortcut\"), but REFUSES + warns if a same-named file lacks the sentinel (never clobber a user file). Requires the additive `--create` flag on `Run{}` (the default-fresh made explicit; N-1-safe)."
required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: shortcut content + sentinel create-vs-update as unit; flag→action mapping unit)

# ───────────────────────────── Install / migration / infra ──────────────────


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

required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)

# ───────────────────────────── Install / migration / infra ──────────────────

required_stages = ["impl", "unit", "int"]  # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end)
# ── M12 Wave 2: interactive `spt endpoint run` picker (M12-PLAN.md Wave 2 / M12-ENDPOINT-RUN-PICKER.md;
#    doyle ruling M12-W2-RULING.md). Pure front-end over existing surfaces — no second bringup path
#    (the gate invariant). doc+impl+unit; NO int — the TUI renders to a testable ratatui Buffer
#    (buffer + state-model asserts as unit) and the live key loop is a manual-verify leg (REQ-PAIR-6
#    precedent); the integrating bringup path is already int-covered by REQ-HOST-RUN-1/REQ-RC-1. ──
[[requirements]]
id = "REQ-RUN-PICKER"
title = "Interactive `spt endpoint run` picker (ratatui TUI): bare `spt endpoint run` (no --adapter/--id) enters an in-process picker (flags-present = the REQ-HOST-RUN-1 non-interactive path, untouched). Layer 1 picks kind (Create new | Pick existing). Create-new: choose a registered kind=\"harness\" adapter with its shipped+local profiles tree-nested (registry::registered / manifest.profiles / local_profile_names) → enter a charset-validated id → start. Pick-existing: category select (left/right) over [<cwd-project> | Local node | Subnet], endpoints grouped + alphabetically sorted per category, a status square per endpoint (online green ■ / offline gray ▢ — the blue \"attached\" tri-state + Kick are DEFERRED to a broker attach-presence slice, M12-W2-RULING Q1), type-to-filter (`/`, nucleo-matcher), a pinned keybind legend, and a right-half two-pane description (harness adapter:profile · best-effort project history newest→oldest from the contextstore p-<project> branches, empty-if-none · `spt endpoint description`). Confirm layer offers status-dependent options — Attach/Start/View (rc pump / cmd_endpoint_run) · Instantiate-locally (remote) · Change-harness-adapter (offline) · Fork (cmd_fork) · Resume-from-history (offline+LOCAL only; enumerate spt_store::sessions::last_k, titles `<project> @ <ts> (…id5)`, feed session_id → cmd_endpoint_run --resume). A single action enum is the source of truth so a future tap-mode (phone PTY) layers on without re-coupling to keybinds. EVERY terminal action routes through cmd_endpoint_run / existing CLI fns — no second bringup path."
required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: TUI Buffer + state-model unit asserts; live loop manual-verify; bringup path int-covered by REQ-HOST-RUN-1/REQ-RC-1)
[[requirements]]
id = "REQ-RUN-SHORTCUT"
title = "`cc-<id>` launcher shortcut generation (picker `s` keybind, M12-W2-T2.4): from any pre-start options set the picker writes/updates a `cc-<id>` launcher at the project root baking the current selection's non-interactive `spt endpoint run` flags (terminal actions only: adapter[:profile] + id + (create|resume) + (start|attach|view); Kick/Instantiate/Change-adapter/Fork are interactive-only, not bakeable). The script is the CURRENT OS's native form — `.ps1` on Windows, POSIX `sh` (+chmod +x) on Unix (a single portable form can't be both). Overwrite is SENTINEL-guarded: the generator writes + checks a generated-by header marker — it overwrites its own prior output freely (\"Update `cc-<id>` shortcut\"), but REFUSES + warns if a same-named file lacks the sentinel (never clobber a user file). Requires the additive `--create` flag on `Run{}` (the default-fresh made explicit; N-1-safe)."
required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: shortcut content + sentinel create-vs-update as unit; flag→action mapping unit)

# ───────────────────────────── Install / migration / infra ──────────────────

title = "Project-scoped working directory for spt-hosted bringup: `spt endpoint run` lands the broker-spawned harness PTY in the user's PROJECT cwd, not the daemon's, via an additive `SpawnReq.cwd` field carried through the broker PTY spawn (portable-pty CommandBuilder cwd). N-1-safe wire change (additive, defaulted). Required because the consumer (Claude Code) is project-scoped: broker-inherited cwd = the daemon's cwd = the wrong `.claude`, wrong session history, wrong digest source; `cc <id>` at a project root MUST land the harness in that project. W1 ships broker-inherited cwd as a bringup-proof shortcut only; this REQ must land before the M12 gate (doyle, 2026-06-14)."

+# ── M12 Wave 2: interactive `spt endpoint run` picker (M12-PLAN.md Wave 2 / M12-ENDPOINT-RUN-PICKER.md;

+#    doyle ruling M12-W2-RULING.md). Pure front-end over existing surfaces — no second bringup path

+#    (the gate invariant). doc+impl+unit; NO int — the TUI renders to a testable ratatui Buffer

+#    (buffer + state-model asserts as unit) and the live key loop is a manual-verify leg (REQ-PAIR-6

+#    precedent); the integrating bringup path is already int-covered by REQ-HOST-RUN-1/REQ-RC-1. ──

+title = "Interactive `spt endpoint run` picker (ratatui TUI): bare `spt endpoint run` (no --adapter/--id) enters an in-process picker (flags-present = the REQ-HOST-RUN-1 non-interactive path, untouched). Layer 1 picks kind (Create new | Pick existing). Create-new: choose a registered kind=\"harness\" adapter with its shipped+local profiles tree-nested (registry::registered / manifest.profiles / local_profile_names) → enter a charset-validated id → start. Pick-existing: category select (left/right) over [<cwd-project> | Local node | Subnet], endpoints grouped + alphabetically sorted per category, a status square per endpoint (online green ■ / offline gray ▢ — the blue \"attached\" tri-state + Kick are DEFERRED to a broker attach-presence slice, M12-W2-RULING Q1), type-to-filter (`/`, nucleo-matcher), a pinned keybind legend, and a right-half two-pane description (harness adapter:profile · best-effort project history newest→oldest from the contextstore p-<project> branches, empty-if-none · `spt endpoint description`). Confirm layer offers status-dependent options — Attach/Start/View (rc pump / cmd_endpoint_run) · Instantiate-locally (remote) · Change-harness-adapter (offline) · Fork (cmd_fork) · Resume-from-history (offline+LOCAL only; enumerate spt_store::sessions::last_k, titles `<project> @ <ts> (…id5)`, feed session_id → cmd_endpoint_run --resume). A single action enum is the source of truth so a future tap-mode (phone PTY) layers on without re-coupling to keybinds. EVERY terminal action routes through cmd_endpoint_run / existing CLI fns — no second bringup path."

+required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: TUI Buffer + state-model unit asserts; live loop manual-verify; bringup path int-covered by REQ-HOST-RUN-1/REQ-RC-1)

+title = "`cc-<id>` launcher shortcut generation (picker `s` keybind, M12-W2-T2.4): from any pre-start options set the picker writes/updates a `cc-<id>` launcher at the project root baking the current selection's non-interactive `spt endpoint run` flags (terminal actions only: adapter[:profile] + id + (create|resume) + (start|attach|view); Kick/Instantiate/Change-adapter/Fork are interactive-only, not bakeable). The script is the CURRENT OS's native form — `.ps1` on Windows, POSIX `sh` (+chmod +x) on Unix (a single portable form can't be both). Overwrite is SENTINEL-guarded: the generator writes + checks a generated-by header marker — it overwrites its own prior output freely (\"Update `cc-<id>` shortcut\"), but REFUSES + warns if a same-named file lacks the sentinel (never clobber a user file). Requires the additive `--create` flag on `Run{}` (the default-fresh made explicit; N-1-safe)."

+required_stages = ["doc", "impl", "unit"]  # activated M12-W2 (no int: shortcut content + sentinel create-vs-update as unit; flag→action mapping unit)


Tasks 1-2 done. Task 3 — deps + Run{} flags. Add deps:


# `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 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"