1	//! Shell-instance perch records (M5-D3a — CONTEXT §Shell model,
2	//! `docs/STORAGE.md` §Shells in the layout).
3	//!
4	//! A shell perch is **not** an agent perch: its `info.json` carries exactly
5	//! `type=Shell, owner, adapter_name, status, alias?` — a dedicated
6	//! [`ShellInfo`] shape, deliberately distinct from [`crate::info::InfoJson`],
7	//! so the agent-perch readers (liveness, registry stale-clean, the consent
8	//! MRA scan) stay blind to shells by construction. Capabilities are **never**
9	//! duplicated here — they resolve from the shell adapter manifest by
10	//! `adapter_name`.
11	//!
12	//! **Identity model (REQ-EP-5):** `spt shell spawn` *mints* a new instance —
13	//! the immutable canonical id `<adapter>-<n>` ([`mint_shell_id`]: smallest
14	//! free `n` over **existing** instance dirs; online and offline both occupy,
15	//! `teardown` frees the slot). The optional **alias** is a per-instance,
16	//! owner-unique display/address overlay set at spawn or by `shell rename` —
17	//! interchangeable with the canonical id for addressing
18	//! ([`resolve_shell_ref`], canonical-first), never obscuring `adapter_name`.
19	//!
20	//! Torn-read stance mirrors `info.rs` (KNOWN-HAZARDS 1.2): writers go through
21	//! the atomic rename; readers tolerate failure and return `None`.
22	// [impl->REQ-EP-5]
23
24	use serde::{Deserialize, Serialize};
25	use std::path::Path;
26
27	use crate::atomic::atomic_write_string;
28	use crate::perch;
29
30	/// `status` value: the shell binary is linked and running.
31	pub const SHELL_STATUS_ONLINE: &str = "online";
32	/// `status` value: the perch exists, no binary linked (re-linkable if the
33	/// adapter is `persistent`; the spawn-time initial state until D3b binds).
34	pub const SHELL_STATUS_OFFLINE: &str = "offline";
35	/// The fixed `type` tag on every shell perch record.
36	pub const SHELL_TYPE: &str = "Shell";
37
38	/// On-disk shape of a shell perch's `info.json` — the full field set, by
39	/// contract (STORAGE.md: "carries only `type=Shell`, `owner`, `adapter_name`,
40	/// `status`, and an optional `alias`").
41	// [impl->REQ-SHELL-1]
42	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43	pub struct ShellInfo {
44	    /// Always [`SHELL_TYPE`] — readers refuse anything else.
45	    pub r#type: String,
46	    /// The owning agent endpoint id — the only id that may drive this shell.
47	    pub owner: String,
48	    /// The providing shell adapter (capability surface resolves from its
49	    /// manifest, never from the perch).
50	    pub adapter_name: String,
51	    /// [`SHELL_STATUS_ONLINE`] | [`SHELL_STATUS_OFFLINE`].
52	    pub status: String,
53	    /// Owner-unique friendly label (runtime state, not a manifest field).
54	    #[serde(default, skip_serializing_if = "Option::is_none")]
55	    pub alias: Option<String>,
56	}
57
58	impl ShellInfo {
59	    /// A freshly-minted instance record: offline until the broker launch +
60	    /// `api bind` handshake onlines it (D3b).
61	    pub fn new(owner: &str, adapter_name: &str) -> Self {
62	        ShellInfo {
63	            r#type: SHELL_TYPE.to_string(),
64	            owner: owner.to_string(),
65	            adapter_name: adapter_name.to_string(),
66	            status: SHELL_STATUS_OFFLINE.to_string(),
67	            alias: None,
68	        }
69	    }
70	}
71
72	/// Shell-instance model failures — CLI-facing, so they carry the why.
73	#[derive(Debug)]
74	pub enum ShellError {
75	    /// The alias is already held by another of this owner's instances, or
76	    /// shadows an existing canonical id.
77	    AliasTaken(String),
78	    /// No instance of this owner matches the ref (canonical id or alias).
79	    NotFound(String),
80	    Io(std::io::Error),
81	}
82
83	impl std::fmt::Display for ShellError {
84	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85	        match self {
86	            ShellError::AliasTaken(a) => write!(f, "alias already in use: {a}"),
87	            ShellError::NotFound(r) => write!(f, "no shell instance matches: {r}"),
88	            ShellError::Io(e) => write!(f, "io: {e}"),
89	        }
90	    }
91	}
92
93	impl From<std::io::Error> for ShellError {
94	    fn from(e: std::io::Error) -> Self {
95	        ShellError::Io(e)
96	    }
97	}
98
99	/// Read a shell perch's record. Tolerant (`None` on missing/corrupt — the
100	/// list path skips, never panics) and shape-checked: a record whose `type`
101	/// isn't [`SHELL_TYPE`] is not a shell perch.
102	pub fn read_shell_info(shell_perch: &Path) -> Option<ShellInfo> {
103	    let s = std::fs::read_to_string(perch::info_file_at(shell_perch)).ok()?;
104	    let info: ShellInfo = serde_json::from_str(&s).ok()?;
105	    (info.r#type == SHELL_TYPE).then_some(info)
106	}
107
108	/// Write a shell perch's record (atomic rename — the 1.2 torn-read stance).
109	/// Creates the perch dir.
110	pub fn write_shell_info(shell_perch: &Path, info: &ShellInfo) -> std::io::Result<()> {
111	    std::fs::create_dir_all(shell_perch)?;
112	    let s = serde_json::to_string_pretty(info).expect("ShellInfo serializes");
113	    atomic_write_string(&perch::info_file_at(shell_perch), &s)
114	}
115
116	/// The **parent** segment of an adapter option: the part before the first `:`
117	/// of a `<adapter>:<profile>` composite (CONTEXT.md §adapter profile). The
118	/// canonical shell id is minted off this — never the composite — so the id (a
119	/// perch dir name + endpoint address) stays colon-free and path-safe; the full
120	/// composite rides only in the `adapter_name` carrier field. Mirrors
121	/// `spt_runtime::profile::split_option`, inlined here to keep spt-store free of
122	/// an spt-runtime dependency (the crate graph runs the other way).
123	fn adapter_parent(option: &str) -> &str {
124	    option.split_once(':').map_or(option, |(a, _)| a)
125	}
126
127	/// Parse the `n` out of a canonical `<parent>-<n>` dir name for `parent`.
128	fn instance_ordinal(dir_name: &str, parent: &str) -> Option<u32> {
129	    let suffix = dir_name.strip_prefix(parent)?.strip_prefix('-')?;
130	    if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
131	        return None;
132	    }
133	    suffix.parse().ok()
134	}
135
136	/// Mint the next canonical id for `adapter` under `owner`: the **smallest
137	/// free `n`** over existing instance dirs. Online and offline both occupy a
138	/// slot (every non-torn-down perch counts — the same all-existing rule the
139	/// instance cap uses); `teardown` removes the dir, so its `n` is reused.
140	///
141	/// `adapter` may be a `<adapter>:<profile>` composite; the id is minted off the
142	/// **parent** segment ([`adapter_parent`]) so two profiles of one adapter share
143	/// the same `<parent>-<n>` ordinal space (instances are the adapter's resource,
144	/// not the profile's) and the id never carries the `:`.
145	// [impl->REQ-EP-5]
146	// [impl->REQ-HAZARD-ID-CHARSET]
147	pub fn mint_shell_id(owlery: &Path, owner: &str, adapter: &str) -> String {
148	    let parent = adapter_parent(adapter);
149	    let shells = perch::resolve_shells_dir_in(owlery, owner);
150	    let mut taken: Vec<u32> = std::fs::read_dir(&shells)
151	        .map(|entries| {
152	            entries
153	                .flatten()
154	                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
155	                .filter_map(|e| instance_ordinal(&e.file_name().to_string_lossy(), parent))
156	                .collect()
157	        })
158	        .unwrap_or_default();
159	    taken.sort_unstable();
160	    let mut n = 0u32;
161	    for t in taken {
162	        if t == n {
163	            n += 1;
164	        } else if t > n {
165	            break;
166	        }
167	    }
168	    format!("{parent}-{n}")
169	}
170
171	/// Every shell instance of `owner`: `(canonical_id, record)`, sorted by id
172	/// for a stable order. Tolerant — a dir without a valid [`ShellInfo`] is
173	/// skipped (it still *occupies* its mint slot; it just doesn't list).
174	// [impl->REQ-EP-5]
175	pub fn list_shells(owlery: &Path, owner: &str) -> Vec<(String, ShellInfo)> {
176	    let shells = perch::resolve_shells_dir_in(owlery, owner);
177	    let Ok(entries) = std::fs::read_dir(&shells) else {
178	        return Vec::new();
179	    };
180	    let mut out: Vec<(String, ShellInfo)> = entries
181	        .flatten()
182	        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
183	        .filter_map(|e| {
184	            let id = e.file_name().to_string_lossy().into_owned();
185	            read_shell_info(&e.path()).map(|info| (id, info))
186	        })
187	        .collect();
188	    out.sort_by(|a, b| a.0.cmp(&b.0));
189	    out
190	}
191
192	/// Resolve a shell ref — canonical id or alias — to `(canonical_id, record)`.
193	/// **Canonical-first**: an alias can never shadow a canonical id (spawn/rename
194	/// refuse minting such an alias, and resolution checks canonical before alias
195	/// as defense in depth). Aliases are owner-unique, so at most one matches.
196	// [impl->REQ-EP-5]
197	pub fn resolve_shell_ref(
198	    owlery: &Path,
199	    owner: &str,
200	    shell_ref: &str,
201	) -> Option<(String, ShellInfo)> {
202	    let direct = perch::resolve_shell_perch_path_in(owlery, owner, shell_ref);
203	    if let Some(info) = read_shell_info(&direct) {
204	        return Some((shell_ref.to_string(), info));
205	    }
206	    list_shells(owlery, owner)
207	        .into_iter()
208	        .find(|(_, info)| info.alias.as_deref() == Some(shell_ref))
209	}
210
211	/// Whether `alias` is free for `owner`: held by no instance's alias and not
212	/// shadowing any existing canonical id.
213	fn alias_free(owlery: &Path, owner: &str, alias: &str) -> bool {
214	    resolve_shell_ref(owlery, owner, alias).is_none()
215	}
216
217	/// Mint + record a new instance of `adapter` for `owner` (the storage half of
218	/// `spt shell spawn` — adapter-registration and consent gates are the
219	/// caller's, per the gate order). Returns the canonical id; the record starts
220	/// offline.
221	// [impl->REQ-EP-5]
222	// [impl->REQ-SHELL-1]
223	pub fn spawn_record(
224	    owlery: &Path,
225	    owner: &str,
226	    adapter: &str,
227	    alias: Option<&str>,
228	) -> Result<String, ShellError> {
229	    if let Some(a) = alias {
230	        if !alias_free(owlery, owner, a) {
231	            return Err(ShellError::AliasTaken(a.to_string()));
232	        }
233	    }
234	    let id = mint_shell_id(owlery, owner, adapter);
235	    let mut info = ShellInfo::new(owner, adapter);
236	    info.alias = alias.map(str::to_string);
237	    write_shell_info(
238	        &perch::resolve_shell_perch_path_in(owlery, owner, &id),
239	        &info,
240	    )?;
241	    Ok(id)
242	}
243
244	/// Set/replace the alias on an existing instance (`shell rename`). The new
245	/// alias must be free for this owner (owner-unique, never shadowing a
246	/// canonical id).
247	// [impl->REQ-EP-5]
248	pub fn rename_shell(
249	    owlery: &Path,
250	    owner: &str,
251	    shell_ref: &str,
252	    alias: &str,
253	) -> Result<String, ShellError> {
254	    let (id, mut info) = resolve_shell_ref(owlery, owner, shell_ref)
255	        .ok_or_else(|| ShellError::NotFound(shell_ref.to_string()))?;
256	    if info.alias.as_deref() != Some(alias) && !alias_free(owlery, owner, alias) {
257	        return Err(ShellError::AliasTaken(alias.to_string()));
258	    }
259	    info.alias = Some(alias.to_string());
260	    write_shell_info(
261	        &perch::resolve_shell_perch_path_in(owlery, owner, &id),
262	        &info,
263	    )?;
264	    Ok(id)
265	}
266
267	/// Destroy an instance: remove its perch dir entirely — frees the mint slot
268	/// and the alias (the lifecycle's torn-down terminal; the D4 link-break/
269	/// pre-close machinery runs before this in the hosted path).
270	// [impl->REQ-EP-5]
271	pub fn teardown_shell(owlery: &Path, owner: &str, shell_ref: &str) -> Result<String, ShellError> {
272	    let (id, _) = resolve_shell_ref(owlery, owner, shell_ref)
273	        .ok_or_else(|| ShellError::NotFound(shell_ref.to_string()))?;
274	    std::fs::remove_dir_all(perch::resolve_shell_perch_path_in(owlery, owner, &id))?;
275	    Ok(id)
276	}
277
278	#[cfg(test)]
279	mod tests {
280	    use super::*;
281
282	    fn spawn(owlery: &Path, owner: &str, adapter: &str, alias: Option<&str>) -> String {
283	        spawn_record(owlery, owner, adapter, alias).expect("spawn_record")
284	    }
285
286	    // [unit->REQ-EP-5] minting: 0,1,2…; teardown frees the slot and the next
287	    // spawn reuses it; offline instances still occupy theirs; a foreign
288	    // adapter's instances never collide with this adapter's numbering.
289	    #[test]
290	    fn mint_smallest_free_and_teardown_frees() {
291	        let tmp = tempfile::tempdir().unwrap();
292	        let owlery = tmp.path();
293
294	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-0");
295	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-1");
296	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-2");
297	        // A different adapter numbers independently.
298	        assert_eq!(spawn(owlery, "doyle", "OsNotify", None), "OsNotify-0");
299
300	        // Teardown the middle instance — its slot is the smallest free.
301	        teardown_shell(owlery, "doyle", "GameRobot-1").unwrap();
302	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-1");
303	        // All occupied again (every record is offline — offline occupies).
304	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-3");
305	        // Another owner's numbering is its own.
306	        assert_eq!(spawn(owlery, "ling", "GameRobot", None), "GameRobot-0");
307	    }
308
309	    // [unit->REQ-HAZARD-ID-CHARSET] [unit->REQ-MANIFEST-2] a composite adapter
310	    // option (`<adapter>:<profile>`) mints a **colon-free** `<parent>-<n>` id
311	    // (`:` is a reserved address delimiter — it must never leak into the id /
312	    // perch dir name), while the full composite rides in `adapter_name`. Two
313	    // profiles of one adapter share the parent's ordinal space — instances are
314	    // the adapter's resource, not the profile's.
315	    #[test]
316	    fn composite_option_mints_colon_free_id_and_carries_the_composite() {
317	        let tmp = tempfile::tempdir().unwrap();
318	        let owlery = tmp.path();
319
320	        // Profiled spawn: id is parent-based + colon-free; carrier is the composite.
321	        let id0 = spawn(owlery, "doyle", "claude-spt:work", None);
322	        assert_eq!(id0, "claude-spt-0", "id minted off the parent, no ':'");
323	        assert!(!id0.contains(':'), "reserved delimiter never in the id");
324	        let info0 = resolve_shell_ref(owlery, "doyle", &id0).unwrap().1;
325	        assert_eq!(info0.adapter_name, "claude-spt:work", "composite carried verbatim");
326
327	        // A different profile of the SAME parent shares the ordinal space…
328	        let id1 = spawn(owlery, "doyle", "claude-spt:play", None);
329	        assert_eq!(id1, "claude-spt-1", "profiles of one parent share <parent>-<n>");
330	        // …and a bare-parent spawn occupies the same space too.
331	        let id2 = spawn(owlery, "doyle", "claude-spt", None);
332	        assert_eq!(id2, "claude-spt-2");
333
334	        // The id resolves back (it is a valid colon-free perch dir name).
335	        assert!(resolve_shell_ref(owlery, "doyle", "claude-spt-0").is_some());
336	    }
337
338	    // [unit->REQ-EP-5] aliasing: set at spawn; owner-unique (duplicate
339	    // refused, other owners unaffected); rename moves it; alias and canonical
340	    // id resolve to the same instance; an alias shadowing a canonical id is
341	    // refused; list never loses adapter_name behind the alias.
342	    #[test]
343	    fn alias_unique_rename_and_resolution() {
344	        let tmp = tempfile::tempdir().unwrap();
345	        let owlery = tmp.path();
346
347	        let id = spawn(owlery, "doyle", "GameRobot", Some("TempleKeeper"));
348	        assert_eq!(id, "GameRobot-0");
349
350	        // Both forms address the same instance.
351	        let by_alias = resolve_shell_ref(owlery, "doyle", "TempleKeeper").unwrap();
352	        let by_id = resolve_shell_ref(owlery, "doyle", "GameRobot-0").unwrap();
353	        assert_eq!(by_alias.0, "GameRobot-0");
354	        assert_eq!(by_alias, by_id);
355	        // The alias never obscures what the shell is.
356	        assert_eq!(by_alias.1.adapter_name, "GameRobot");
357
358	        // Duplicate alias for the same owner refuses…
359	        assert!(matches!(
360	            spawn_record(owlery, "doyle", "GameRobot", Some("TempleKeeper")),
361	            Err(ShellError::AliasTaken(_))
362	        ));
363	        // …but another owner may hold the same label.
364	        spawn_record(owlery, "ling", "GameRobot", Some("TempleKeeper")).unwrap();
365
366	        // An alias that shadows an existing canonical id refuses.
367	        assert!(matches!(
368	            spawn_record(owlery, "doyle", "OsNotify", Some("GameRobot-0")),
369	            Err(ShellError::AliasTaken(_))
370	        ));
371
372	        // Rename moves the alias; the old label stops resolving, the new one
373	        // reaches the same instance; renaming onto a taken alias refuses;
374	        // re-asserting an instance's own alias is idempotent.
375	        let second = spawn(owlery, "doyle", "GameRobot", None);
376	        assert!(matches!(
377	            rename_shell(owlery, "doyle", &second, "TempleKeeper"),
378	            Err(ShellError::AliasTaken(_))
379	        ));
380	        rename_shell(owlery, "doyle", "TempleKeeper", "Keeper").unwrap();
381	        assert!(resolve_shell_ref(owlery, "doyle", "TempleKeeper").is_none());
382	        assert_eq!(
383	            resolve_shell_ref(owlery, "doyle", "Keeper").unwrap().0,
384	            "GameRobot-0"
385	        );
386	        rename_shell(owlery, "doyle", "Keeper", "Keeper").unwrap();
387
388	        // Unknown ref errs.
389	        assert!(matches!(
390	            rename_shell(owlery, "doyle", "ghost", "X"),
391	            Err(ShellError::NotFound(_))
392	        ));
393	        assert!(matches!(
394	            teardown_shell(owlery, "doyle", "ghost"),
395	            Err(ShellError::NotFound(_))
396	        ));
397	    }
398
399	    // [unit->REQ-SHELL-1] the record roundtrips with the full contracted
400	    // field set and nothing else; a corrupt/foreign info.json is tolerated on
401	    // list (skipped, never a panic) while still occupying its mint slot.
402	    #[test]
403	    fn shellinfo_roundtrip_and_corrupt_tolerance() {
404	        let tmp = tempfile::tempdir().unwrap();
405	        let owlery = tmp.path();
406
407	        spawn(owlery, "doyle", "GameRobot", Some("TempleKeeper"));
408	        let p = perch::resolve_shell_perch_path_in(owlery, "doyle", "GameRobot-0");
409	        let info = read_shell_info(&p).unwrap();
410	        assert_eq!(info.r#type, SHELL_TYPE);
411	        assert_eq!(info.owner, "doyle");
412	        assert_eq!(info.adapter_name, "GameRobot");
413	        assert_eq!(info.status, SHELL_STATUS_OFFLINE);
414	        assert_eq!(info.alias.as_deref(), Some("TempleKeeper"));
415	        // The serialized shape is exactly the contracted field set.
416	        let raw: serde_json::Value =
417	            serde_json::from_str(&std::fs::read_to_string(perch::info_file_at(&p)).unwrap())
418	                .unwrap();
419	        let mut keys: Vec<_> = raw.as_object().unwrap().keys().cloned().collect();
420	        keys.sort();
421	        assert_eq!(keys, ["adapter_name", "alias", "owner", "status", "type"]);
422
423	        // Corrupt a second instance's record: it drops from list (tolerant)…
424	        spawn(owlery, "doyle", "GameRobot", None);
425	        let p1 = perch::resolve_shell_perch_path_in(owlery, "doyle", "GameRobot-1");
426	        std::fs::write(perch::info_file_at(&p1), "{not json").unwrap();
427	        let listed = list_shells(owlery, "doyle");
428	        assert_eq!(listed.len(), 1);
429	        assert_eq!(listed[0].0, "GameRobot-0");
430	        // …an agent-shaped info.json is not a shell record either…
431	        std::fs::write(
432	            perch::info_file_at(&p1),
433	            r#"{"type":"LiveAgent","owner":"x","adapter_name":"y","status":"online"}"#,
434	        )
435	        .unwrap();
436	        assert!(read_shell_info(&p1).is_none());
437	        // …but the dir still occupies its mint slot.
438	        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-2");
439
440	        // No shells dir at all → empty list, no panic.
441	        assert!(list_shells(owlery, "nobody").is_empty());
442	    }
443	}
444

//! Shell-instance perch records (M5-D3a — CONTEXT §Shell model,
//! `docs/STORAGE.md` §Shells in the layout).
//!
//! A shell perch is **not** an agent perch: its `info.json` carries exactly
//! `type=Shell, owner, adapter_name, status, alias?` — a dedicated
//! [`ShellInfo`] shape, deliberately distinct from [`crate::info::InfoJson`],
//! so the agent-perch readers (liveness, registry stale-clean, the consent
//! MRA scan) stay blind to shells by construction. Capabilities are **never**
//! duplicated here — they resolve from the shell adapter manifest by
//! `adapter_name`.
//!
//! **Identity model (REQ-EP-5):** `spt shell spawn` *mints* a new instance —
//! the immutable canonical id `<adapter>-<n>` ([`mint_shell_id`]: smallest
//! free `n` over **existing** instance dirs; online and offline both occupy,
//! `teardown` frees the slot). The optional **alias** is a per-instance,
//! owner-unique display/address overlay set at spawn or by `shell rename` —
//! interchangeable with the canonical id for addressing
//! ([`resolve_shell_ref`], canonical-first), never obscuring `adapter_name`.
//!
//! Torn-read stance mirrors `info.rs` (KNOWN-HAZARDS 1.2): writers go through
//! the atomic rename; readers tolerate failure and return `None`.
// [impl->REQ-EP-5]

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;
use crate::perch;

/// `status` value: the shell binary is linked and running.
pub const SHELL_STATUS_ONLINE: &str = "online";
/// `status` value: the perch exists, no binary linked (re-linkable if the
/// adapter is `persistent`; the spawn-time initial state until D3b binds).
pub const SHELL_STATUS_OFFLINE: &str = "offline";
/// The fixed `type` tag on every shell perch record.
pub const SHELL_TYPE: &str = "Shell";

/// On-disk shape of a shell perch's `info.json` — the full field set, by
/// contract (STORAGE.md: "carries only `type=Shell`, `owner`, `adapter_name`,
/// `status`, and an optional `alias`").
// [impl->REQ-SHELL-1]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShellInfo {
    /// Always [`SHELL_TYPE`] — readers refuse anything else.
    pub r#type: String,
    /// The owning agent endpoint id — the only id that may drive this shell.
    pub owner: String,
    /// The providing shell adapter (capability surface resolves from its
    /// manifest, never from the perch).
    pub adapter_name: String,
    /// [`SHELL_STATUS_ONLINE`] | [`SHELL_STATUS_OFFLINE`].
    pub status: String,
    /// Owner-unique friendly label (runtime state, not a manifest field).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
}

impl ShellInfo {
    /// A freshly-minted instance record: offline until the broker launch +
    /// `api bind` handshake onlines it (D3b).
    pub fn new(owner: &str, adapter_name: &str) -> Self {
        ShellInfo {
            r#type: SHELL_TYPE.to_string(),
            owner: owner.to_string(),
            adapter_name: adapter_name.to_string(),
            status: SHELL_STATUS_OFFLINE.to_string(),
            alias: None,
        }
    }
}

/// Shell-instance model failures — CLI-facing, so they carry the why.
#[derive(Debug)]
pub enum ShellError {
    /// The alias is already held by another of this owner's instances, or
    /// shadows an existing canonical id.
    AliasTaken(String),
    /// No instance of this owner matches the ref (canonical id or alias).
    NotFound(String),
    Io(std::io::Error),
}

impl std::fmt::Display for ShellError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ShellError::AliasTaken(a) => write!(f, "alias already in use: {a}"),
            ShellError::NotFound(r) => write!(f, "no shell instance matches: {r}"),
            ShellError::Io(e) => write!(f, "io: {e}"),
        }
    }
}

impl From<std::io::Error> for ShellError {
    fn from(e: std::io::Error) -> Self {
        ShellError::Io(e)
    }
}

/// Read a shell perch's record. Tolerant (`None` on missing/corrupt — the
/// list path skips, never panics) and shape-checked: a record whose `type`
/// isn't [`SHELL_TYPE`] is not a shell perch.
pub fn read_shell_info(shell_perch: &Path) -> Option<ShellInfo> {
    let s = std::fs::read_to_string(perch::info_file_at(shell_perch)).ok()?;
    let info: ShellInfo = serde_json::from_str(&s).ok()?;
    (info.r#type == SHELL_TYPE).then_some(info)
}

/// Write a shell perch's record (atomic rename — the 1.2 torn-read stance).
/// Creates the perch dir.
pub fn write_shell_info(shell_perch: &Path, info: &ShellInfo) -> std::io::Result<()> {
    std::fs::create_dir_all(shell_perch)?;
    let s = serde_json::to_string_pretty(info).expect("ShellInfo serializes");
    atomic_write_string(&perch::info_file_at(shell_perch), &s)
}

/// The **parent** segment of an adapter option: the part before the first `:`
/// of a `<adapter>:<profile>` composite (CONTEXT.md §adapter profile). The
/// canonical shell id is minted off this — never the composite — so the id (a
/// perch dir name + endpoint address) stays colon-free and path-safe; the full
/// composite rides only in the `adapter_name` carrier field. Mirrors
/// `spt_runtime::profile::split_option`, inlined here to keep spt-store free of
/// an spt-runtime dependency (the crate graph runs the other way).
fn adapter_parent(option: &str) -> &str {
    option.split_once(':').map_or(option, |(a, _)| a)
}

/// Parse the `n` out of a canonical `<parent>-<n>` dir name for `parent`.
fn instance_ordinal(dir_name: &str, parent: &str) -> Option<u32> {
    let suffix = dir_name.strip_prefix(parent)?.strip_prefix('-')?;
    if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    suffix.parse().ok()
}

/// Mint the next canonical id for `adapter` under `owner`: the **smallest
/// free `n`** over existing instance dirs. Online and offline both occupy a
/// slot (every non-torn-down perch counts — the same all-existing rule the
/// instance cap uses); `teardown` removes the dir, so its `n` is reused.
///
/// `adapter` may be a `<adapter>:<profile>` composite; the id is minted off the
/// **parent** segment ([`adapter_parent`]) so two profiles of one adapter share
/// the same `<parent>-<n>` ordinal space (instances are the adapter's resource,
/// not the profile's) and the id never carries the `:`.
// [impl->REQ-EP-5]
// [impl->REQ-HAZARD-ID-CHARSET]
pub fn mint_shell_id(owlery: &Path, owner: &str, adapter: &str) -> String {
    let parent = adapter_parent(adapter);
    let shells = perch::resolve_shells_dir_in(owlery, owner);
    let mut taken: Vec<u32> = std::fs::read_dir(&shells)
        .map(|entries| {
            entries
                .flatten()
                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
                .filter_map(|e| instance_ordinal(&e.file_name().to_string_lossy(), parent))
                .collect()
        })
        .unwrap_or_default();
    taken.sort_unstable();
    let mut n = 0u32;
    for t in taken {
        if t == n {
            n += 1;
        } else if t > n {
            break;
        }
    }
    format!("{parent}-{n}")
}

/// Every shell instance of `owner`: `(canonical_id, record)`, sorted by id
/// for a stable order. Tolerant — a dir without a valid [`ShellInfo`] is
/// skipped (it still *occupies* its mint slot; it just doesn't list).
// [impl->REQ-EP-5]
pub fn list_shells(owlery: &Path, owner: &str) -> Vec<(String, ShellInfo)> {
    let shells = perch::resolve_shells_dir_in(owlery, owner);
    let Ok(entries) = std::fs::read_dir(&shells) else {
        return Vec::new();
    };
    let mut out: Vec<(String, ShellInfo)> = entries
        .flatten()
        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
        .filter_map(|e| {
            let id = e.file_name().to_string_lossy().into_owned();
            read_shell_info(&e.path()).map(|info| (id, info))
        })
        .collect();
    out.sort_by(|a, b| a.0.cmp(&b.0));
    out
}

/// Resolve a shell ref — canonical id or alias — to `(canonical_id, record)`.
/// **Canonical-first**: an alias can never shadow a canonical id (spawn/rename
/// refuse minting such an alias, and resolution checks canonical before alias
/// as defense in depth). Aliases are owner-unique, so at most one matches.
// [impl->REQ-EP-5]
pub fn resolve_shell_ref(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Option<(String, ShellInfo)> {
    let direct = perch::resolve_shell_perch_path_in(owlery, owner, shell_ref);
    if let Some(info) = read_shell_info(&direct) {
        return Some((shell_ref.to_string(), info));
    }
    list_shells(owlery, owner)
        .into_iter()
        .find(|(_, info)| info.alias.as_deref() == Some(shell_ref))
}

/// Whether `alias` is free for `owner`: held by no instance's alias and not
/// shadowing any existing canonical id.
fn alias_free(owlery: &Path, owner: &str, alias: &str) -> bool {
    resolve_shell_ref(owlery, owner, alias).is_none()
}

/// Mint + record a new instance of `adapter` for `owner` (the storage half of
/// `spt shell spawn` — adapter-registration and consent gates are the
/// caller's, per the gate order). Returns the canonical id; the record starts
/// offline.
// [impl->REQ-EP-5]
// [impl->REQ-SHELL-1]
pub fn spawn_record(
    owlery: &Path,
    owner: &str,
    adapter: &str,
    alias: Option<&str>,
) -> Result<String, ShellError> {
    if let Some(a) = alias {
        if !alias_free(owlery, owner, a) {
            return Err(ShellError::AliasTaken(a.to_string()));
        }
    }
    let id = mint_shell_id(owlery, owner, adapter);
    let mut info = ShellInfo::new(owner, adapter);
    info.alias = alias.map(str::to_string);
    write_shell_info(
        &perch::resolve_shell_perch_path_in(owlery, owner, &id),
        &info,
    )?;
    Ok(id)
}

/// Set/replace the alias on an existing instance (`shell rename`). The new
/// alias must be free for this owner (owner-unique, never shadowing a
/// canonical id).
// [impl->REQ-EP-5]
pub fn rename_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    alias: &str,
) -> Result<String, ShellError> {
    let (id, mut info) = resolve_shell_ref(owlery, owner, shell_ref)
        .ok_or_else(|| ShellError::NotFound(shell_ref.to_string()))?;
    if info.alias.as_deref() != Some(alias) && !alias_free(owlery, owner, alias) {
        return Err(ShellError::AliasTaken(alias.to_string()));
    }
    info.alias = Some(alias.to_string());
    write_shell_info(
        &perch::resolve_shell_perch_path_in(owlery, owner, &id),
        &info,
    )?;
    Ok(id)
}

/// Destroy an instance: remove its perch dir entirely — frees the mint slot
/// and the alias (the lifecycle's torn-down terminal; the D4 link-break/
/// pre-close machinery runs before this in the hosted path).
// [impl->REQ-EP-5]
pub fn teardown_shell(owlery: &Path, owner: &str, shell_ref: &str) -> Result<String, ShellError> {
    let (id, _) = resolve_shell_ref(owlery, owner, shell_ref)
        .ok_or_else(|| ShellError::NotFound(shell_ref.to_string()))?;
    std::fs::remove_dir_all(perch::resolve_shell_perch_path_in(owlery, owner, &id))?;
    Ok(id)
}

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

    fn spawn(owlery: &Path, owner: &str, adapter: &str, alias: Option<&str>) -> String {
        spawn_record(owlery, owner, adapter, alias).expect("spawn_record")
    }

    // [unit->REQ-EP-5] minting: 0,1,2…; teardown frees the slot and the next
    // spawn reuses it; offline instances still occupy theirs; a foreign
    // adapter's instances never collide with this adapter's numbering.
    #[test]
    fn mint_smallest_free_and_teardown_frees() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();

        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-0");
        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-1");
        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-2");
        // A different adapter numbers independently.
        assert_eq!(spawn(owlery, "doyle", "OsNotify", None), "OsNotify-0");

        // Teardown the middle instance — its slot is the smallest free.
        teardown_shell(owlery, "doyle", "GameRobot-1").unwrap();
        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-1");
        // All occupied again (every record is offline — offline occupies).
        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-3");
        // Another owner's numbering is its own.
        assert_eq!(spawn(owlery, "ling", "GameRobot", None), "GameRobot-0");
    }

    // [unit->REQ-HAZARD-ID-CHARSET] [unit->REQ-MANIFEST-2] a composite adapter
    // option (`<adapter>:<profile>`) mints a **colon-free** `<parent>-<n>` id
    // (`:` is a reserved address delimiter — it must never leak into the id /
    // perch dir name), while the full composite rides in `adapter_name`. Two
    // profiles of one adapter share the parent's ordinal space — instances are
    // the adapter's resource, not the profile's.
    #[test]
    fn composite_option_mints_colon_free_id_and_carries_the_composite() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();

        // Profiled spawn: id is parent-based + colon-free; carrier is the composite.
        let id0 = spawn(owlery, "doyle", "claude-spt:work", None);
        assert_eq!(id0, "claude-spt-0", "id minted off the parent, no ':'");
        assert!(!id0.contains(':'), "reserved delimiter never in the id");
        let info0 = resolve_shell_ref(owlery, "doyle", &id0).unwrap().1;
        assert_eq!(info0.adapter_name, "claude-spt:work", "composite carried verbatim");

        // A different profile of the SAME parent shares the ordinal space…
        let id1 = spawn(owlery, "doyle", "claude-spt:play", None);
        assert_eq!(id1, "claude-spt-1", "profiles of one parent share <parent>-<n>");
        // …and a bare-parent spawn occupies the same space too.
        let id2 = spawn(owlery, "doyle", "claude-spt", None);
        assert_eq!(id2, "claude-spt-2");

        // The id resolves back (it is a valid colon-free perch dir name).
        assert!(resolve_shell_ref(owlery, "doyle", "claude-spt-0").is_some());
    }

    // [unit->REQ-EP-5] aliasing: set at spawn; owner-unique (duplicate
    // refused, other owners unaffected); rename moves it; alias and canonical
    // id resolve to the same instance; an alias shadowing a canonical id is
    // refused; list never loses adapter_name behind the alias.
    #[test]
    fn alias_unique_rename_and_resolution() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();

        let id = spawn(owlery, "doyle", "GameRobot", Some("TempleKeeper"));
        assert_eq!(id, "GameRobot-0");

        // Both forms address the same instance.
        let by_alias = resolve_shell_ref(owlery, "doyle", "TempleKeeper").unwrap();
        let by_id = resolve_shell_ref(owlery, "doyle", "GameRobot-0").unwrap();
        assert_eq!(by_alias.0, "GameRobot-0");
        assert_eq!(by_alias, by_id);
        // The alias never obscures what the shell is.
        assert_eq!(by_alias.1.adapter_name, "GameRobot");

        // Duplicate alias for the same owner refuses…
        assert!(matches!(
            spawn_record(owlery, "doyle", "GameRobot", Some("TempleKeeper")),
            Err(ShellError::AliasTaken(_))
        ));
        // …but another owner may hold the same label.
        spawn_record(owlery, "ling", "GameRobot", Some("TempleKeeper")).unwrap();

        // An alias that shadows an existing canonical id refuses.
        assert!(matches!(
            spawn_record(owlery, "doyle", "OsNotify", Some("GameRobot-0")),
            Err(ShellError::AliasTaken(_))
        ));

        // Rename moves the alias; the old label stops resolving, the new one
        // reaches the same instance; renaming onto a taken alias refuses;
        // re-asserting an instance's own alias is idempotent.
        let second = spawn(owlery, "doyle", "GameRobot", None);
        assert!(matches!(
            rename_shell(owlery, "doyle", &second, "TempleKeeper"),
            Err(ShellError::AliasTaken(_))
        ));
        rename_shell(owlery, "doyle", "TempleKeeper", "Keeper").unwrap();
        assert!(resolve_shell_ref(owlery, "doyle", "TempleKeeper").is_none());
        assert_eq!(
            resolve_shell_ref(owlery, "doyle", "Keeper").unwrap().0,
            "GameRobot-0"
        );
        rename_shell(owlery, "doyle", "Keeper", "Keeper").unwrap();

        // Unknown ref errs.
        assert!(matches!(
            rename_shell(owlery, "doyle", "ghost", "X"),
            Err(ShellError::NotFound(_))
        ));
        assert!(matches!(
            teardown_shell(owlery, "doyle", "ghost"),
            Err(ShellError::NotFound(_))
        ));
    }

    // [unit->REQ-SHELL-1] the record roundtrips with the full contracted
    // field set and nothing else; a corrupt/foreign info.json is tolerated on
    // list (skipped, never a panic) while still occupying its mint slot.
    #[test]
    fn shellinfo_roundtrip_and_corrupt_tolerance() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();

        spawn(owlery, "doyle", "GameRobot", Some("TempleKeeper"));
        let p = perch::resolve_shell_perch_path_in(owlery, "doyle", "GameRobot-0");
        let info = read_shell_info(&p).unwrap();
        assert_eq!(info.r#type, SHELL_TYPE);
        assert_eq!(info.owner, "doyle");
        assert_eq!(info.adapter_name, "GameRobot");
        assert_eq!(info.status, SHELL_STATUS_OFFLINE);
        assert_eq!(info.alias.as_deref(), Some("TempleKeeper"));
        // The serialized shape is exactly the contracted field set.
        let raw: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(perch::info_file_at(&p)).unwrap())
                .unwrap();
        let mut keys: Vec<_> = raw.as_object().unwrap().keys().cloned().collect();
        keys.sort();
        assert_eq!(keys, ["adapter_name", "alias", "owner", "status", "type"]);

        // Corrupt a second instance's record: it drops from list (tolerant)…
        spawn(owlery, "doyle", "GameRobot", None);
        let p1 = perch::resolve_shell_perch_path_in(owlery, "doyle", "GameRobot-1");
        std::fs::write(perch::info_file_at(&p1), "{not json").unwrap();
        let listed = list_shells(owlery, "doyle");
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].0, "GameRobot-0");
        // …an agent-shaped info.json is not a shell record either…
        std::fs::write(
            perch::info_file_at(&p1),
            r#"{"type":"LiveAgent","owner":"x","adapter_name":"y","status":"online"}"#,
        )
        .unwrap();
        assert!(read_shell_info(&p1).is_none());
        // …but the dir still occupies its mint slot.
        assert_eq!(spawn(owlery, "doyle", "GameRobot", None), "GameRobot-2");

        // No shells dir at all → empty list, no panic.
        assert!(list_shells(owlery, "nobody").is_empty());
    }
}


Canon-id hazard closed cleanly — `instance_ordinal` anchors on the known `parent` prefix then requires all-ASCII-digit suffix, which is more robust than the survey's proposed "split on last `-`" (no false collisions even when one adapter name is a prefix of another). REQ-HAZARD-ID-CHARSET minted with unit. Now the comparison seams (#4/#6) and the three daemon resolution seams.


370	        ApiCmd::Boundary {
371	            mode,
372	            id,
373	            to_session,
374	            auth,
375	        } => gated(&id, &auth, |id| {
376	            reporting::cmd_boundary(id, &mode, &to_session)
377	        }),
378	        ApiCmd::SessionEnd { id, erase, auth } => {
379	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
380	        }
381	        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
382	        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
383	        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
384	        ApiCmd::Shutdown { id, auth } => {
385	            let manifest = ctx.manifest.as_ref();
386	            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
387	        }
388	        ApiCmd::Emit {
389	            id,
390	            r#type,
391	            payload,
392	            link,
393	        } => reporting::cmd_emit(&id, &r#type, &payload, &link),
394	        ApiCmd::OwnerShutdown { id, link } => reporting::cmd_owner_shutdown(&id, &link),
395	    }
396	}
397
398	/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
399	/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
400	/// composite (M9 survey #4/#12): the **parent** segment must match the file's
401	/// `adapter.name` (never the whole composite — a profile is not a separate
402	/// adapter), and the `:profile` overlay (shipped from the file's own
403	/// `[profiles]`, local from `adapters_dir`) is applied so every downstream
404	/// advertisement — hostable_types, `[env]`, `[session.notif]` (#5) — reflects
405	/// the profile. Returns an exit code on failure (already reported to stderr).
406	fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
407	    let text = std::fs::read_to_string(path).map_err(|e| {
408	        eprintln!("MANIFEST_READ:{}: {e}", path.display());
409	        EXIT_REFUSED
410	    })?;
411	    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
412	        eprintln!("MANIFEST_INVALID:{e}");
413	        EXIT_REFUSED
414	    })?;
415	    let parent = spt_runtime::profile::split_option(adapter).0;
416	    if manifest.adapter.name != parent {
417	        eprintln!(
418	            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
419	            manifest.adapter.name
420	        );
421	        return Err(EXIT_REFUSED);
422	    }
423	    let adapters_dir = spt_store::perch::adapters_dir();
424	    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
425	        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");
426	        EXIT_REFUSED
427	    })
428	}
429

        ApiCmd::Boundary {
            mode,
            id,
            to_session,
            auth,
        } => gated(&id, &auth, |id| {
            reporting::cmd_boundary(id, &mode, &to_session)
        }),
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::Shutdown { id, auth } => {
            let manifest = ctx.manifest.as_ref();
            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
        }
        ApiCmd::Emit {
            id,
            r#type,
            payload,
            link,
        } => reporting::cmd_emit(&id, &r#type, &payload, &link),
        ApiCmd::OwnerShutdown { id, link } => reporting::cmd_owner_shutdown(&id, &link),
    }
}

/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
/// composite (M9 survey #4/#12): the **parent** segment must match the file's
/// `adapter.name` (never the whole composite — a profile is not a separate
/// adapter), and the `:profile` overlay (shipped from the file's own
/// `[profiles]`, local from `adapters_dir`) is applied so every downstream
/// advertisement — hostable_types, `[env]`, `[session.notif]` (#5) — reflects
/// the profile. Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    let parent = spt_runtime::profile::split_option(adapter).0;
    if manifest.adapter.name != parent {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    let adapters_dir = spt_store::perch::adapters_dir();
    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");
        EXIT_REFUSED
    })
}


430	        shellinfo::teardown_shell(owlery, owner, shell_id)
431	            .map_err(|e| format!("ephemeral teardown: {e:?}"))?;
432	        Ok(CloseOutcome::TornDown)
433	    } else {
434	        let mut info = shellinfo::read_shell_info(&perch)
435	            .ok_or_else(|| format!("{shell_id}: shell record unreadable at close"))?;
436	        info.status = SHELL_STATUS_OFFLINE.to_string();
437	        shellinfo::write_shell_info(&perch, &info).map_err(|e| format!("flip offline: {e}"))?;
438	        Ok(CloseOutcome::Offline)
439	    }
440	}
441
442	/// Resolve one adapter **option**'s `[shell]` section through the merged view
443	/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
444	/// string, split → parent lookup in `registered` → profile overlay (shipped or
445	/// local under `adapters_dir`). Returns an **owned** [`Shell`] — a profile
446	/// yields a fresh merged manifest, not a borrow into the parent slice. A bare
447	/// name resolves to the parent unmodified; a deregistered parent or a failed
448	/// overlay yields `None`.
449	fn shell_section_of(
450	    registered: &[(AdapterRecord, Manifest)],
451	    adapters_dir: &Path,
452	    adapter_option: &str,
453	) -> Option<Shell> {
454	    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
455	        .ok()
456	        .filter(|m| m.adapter.kind == AdapterKind::Shell)
457	        .and_then(|m| m.shell)
458	}
459
460	/// The owner-edge shell cascade (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell
461	/// sleep/wake): owner **suspended** ("offline" for shells — dormant is
462	/// resting-*warm*, shells ride through it) ⇒ every online shell closes via
463	/// [`close_shell`] with its per-shell divergence; owner **woken** ⇒ every
464	/// `persistent` offline shell relaunches ([`launch_shell`] — bind onlines it,
465	/// the D3b contract; non-persistent stay offline for the wake-watcher/relink).
466	/// Best-effort per shell, like the echo gate + wake effects beside it: one
467	/// failed shell never wedges the rest edge or its siblings. Returns the
468	/// per-shell failures for the caller to surface.
469	// [impl->REQ-SHELL-2]
470	pub fn cascade_owner_edge(
471	    owlery: &Path,
472	    owner: &str,
473	    registered: &[(AdapterRecord, Manifest)],
474	    adapters_dir: &Path,
475	    to_suspended: bool,
476	    to_active: bool,
477	) -> Vec<String> {
478	    let mut failures = Vec::new();
479	    for (shell_id, info) in shellinfo::list_shells(owlery, owner) {
480	        // The stored adapter_name is the composite option — resolve it through
481	        // the merged view so a profiled shell rides its overlay, not its parent.
482	        let shell = shell_section_of(registered, adapters_dir, &info.adapter_name);
483	        if to_suspended && info.status == SHELL_STATUS_ONLINE {
484	            if let Err(e) = close_shell(owlery, owner, &shell_id, shell.as_ref()) {
485	                failures.push(format!("{shell_id}: close: {e}"));
486	            }
487	        } else if to_active && info.status == SHELL_STATUS_OFFLINE {
488	            if let Some(shell) = shell.as_ref().filter(|s| s.persistent) {
489	                if let Err(e) = launch_shell(owlery, owner, &shell_id, &info.adapter_name, shell) {

        shellinfo::teardown_shell(owlery, owner, shell_id)
            .map_err(|e| format!("ephemeral teardown: {e:?}"))?;
        Ok(CloseOutcome::TornDown)
    } else {
        let mut info = shellinfo::read_shell_info(&perch)
            .ok_or_else(|| format!("{shell_id}: shell record unreadable at close"))?;
        info.status = SHELL_STATUS_OFFLINE.to_string();
        shellinfo::write_shell_info(&perch, &info).map_err(|e| format!("flip offline: {e}"))?;
        Ok(CloseOutcome::Offline)
    }
}

/// Resolve one adapter **option**'s `[shell]` section through the merged view
/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
/// string, split → parent lookup in `registered` → profile overlay (shipped or
/// local under `adapters_dir`). Returns an **owned** [`Shell`] — a profile
/// yields a fresh merged manifest, not a borrow into the parent slice. A bare
/// name resolves to the parent unmodified; a deregistered parent or a failed
/// overlay yields `None`.
fn shell_section_of(
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    adapter_option: &str,
) -> Option<Shell> {
    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
        .ok()
        .filter(|m| m.adapter.kind == AdapterKind::Shell)
        .and_then(|m| m.shell)
}

/// The owner-edge shell cascade (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell
/// sleep/wake): owner **suspended** ("offline" for shells — dormant is
/// resting-*warm*, shells ride through it) ⇒ every online shell closes via
/// [`close_shell`] with its per-shell divergence; owner **woken** ⇒ every
/// `persistent` offline shell relaunches ([`launch_shell`] — bind onlines it,
/// the D3b contract; non-persistent stay offline for the wake-watcher/relink).
/// Best-effort per shell, like the echo gate + wake effects beside it: one
/// failed shell never wedges the rest edge or its siblings. Returns the
/// per-shell failures for the caller to surface.
// [impl->REQ-SHELL-2]
pub fn cascade_owner_edge(
    owlery: &Path,
    owner: &str,
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    to_suspended: bool,
    to_active: bool,
) -> Vec<String> {
    let mut failures = Vec::new();
    for (shell_id, info) in shellinfo::list_shells(owlery, owner) {
        // The stored adapter_name is the composite option — resolve it through
        // the merged view so a profiled shell rides its overlay, not its parent.
        let shell = shell_section_of(registered, adapters_dir, &info.adapter_name);
        if to_suspended && info.status == SHELL_STATUS_ONLINE {
            if let Err(e) = close_shell(owlery, owner, &shell_id, shell.as_ref()) {
                failures.push(format!("{shell_id}: close: {e}"));
            }
        } else if to_active && info.status == SHELL_STATUS_OFFLINE {
            if let Some(shell) = shell.as_ref().filter(|s| s.persistent) {
                if let Err(e) = launch_shell(owlery, owner, &shell_id, &info.adapter_name, shell) {


#4 (api parent-only compare → overlay) and #1 (shellhost owned-Shell resolve) both correct. Checking shellwake (#3 relaunch re-resolve), linkhost (#2 cap resolution), cli filter (#6), then the hints api surface.


385	        crate::resthost::RestRequestOutcome::Edge(d) => Ok(format!("woke ({d})")),
386	        crate::resthost::RestRequestOutcome::NoEdge => Ok("already awake".to_string()),
387	        crate::resthost::RestRequestOutcome::Failed(e) => Err(e),
388	        crate::resthost::RestRequestOutcome::NoReply => {
389	            Err("refused or dropped (no reply)".to_string())
390	        }
391	    }
392	}
393
394	/// Resolve one adapter **option**'s `[shell]` section through the merged view
395	/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
396	/// string, split → parent lookup in `registered` → profile overlay (shipped or
397	/// local). Returns an **owned** [`Shell`] — a profile produces a fresh merged
398	/// manifest, not a borrow into the parent slice. A bare name resolves to the
399	/// parent unmodified; a deregistered parent or a failed overlay yields `None`.
400	fn shell_section_of(
401	    registered: &[(AdapterRecord, Manifest)],
402	    adapters_dir: &Path,
403	    adapter_option: &str,
404	) -> Option<Shell> {
405	    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
406	        .ok()
407	        .filter(|m| m.adapter.kind == AdapterKind::Shell)
408	        .and_then(|m| m.shell)
409	}
410
411	/// Fill a `wake_command` template. The substitution catalog matches the spawn
412	/// template's (`id`, `adapter_name`, `link_token`) so one manifest vocabulary
413	/// serves both — but the waker's token is minted **unparked**: it substitutes
414	/// (template compatibility) without ever verifying against the perch, because
415	/// an offline link has no live credential by design (the close retired it; a
416	/// waker wakes by *exit code*, not by driving the link).
417	fn now_ms() -> u64 {
418	    crate::brain::now_ms()
419	}
420
421	fn fill_wake_command(
422	    shell_id: &str,
423	    adapter_name: &str,
424	    wake_command: &str,
425	) -> Result<Vec<String>, String> {
426	    let keys = std::collections::BTreeMap::from([
427	        ("id".to_string(), shell_id.to_string()),
428	        ("adapter_name".to_string(), adapter_name.to_string()),
429	        (
430	            "link_token".to_string(),
431	            crate::shellhost::mint_link_token(),
432	        ),
433	    ]);
434	    let filled =
435	        spt_runtime::runtime::fill_template(wake_command, &keys).map_err(|e| e.to_string())?;
436	    let tokens = spt_runtime::runtime::tokenize(&filled);
437	    if tokens.is_empty() {
438	        return Err("empty wake_command".into());
439	    }
440	    Ok(tokens)
441	}
442
443	/// One reconcile sweep — the invariant holder for the online/offline mutual
444	/// exclusivity (CONTEXT: "spt-core flips between them"):

        crate::resthost::RestRequestOutcome::Edge(d) => Ok(format!("woke ({d})")),
        crate::resthost::RestRequestOutcome::NoEdge => Ok("already awake".to_string()),
        crate::resthost::RestRequestOutcome::Failed(e) => Err(e),
        crate::resthost::RestRequestOutcome::NoReply => {
            Err("refused or dropped (no reply)".to_string())
        }
    }
}

/// Resolve one adapter **option**'s `[shell]` section through the merged view
/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
/// string, split → parent lookup in `registered` → profile overlay (shipped or
/// local). Returns an **owned** [`Shell`] — a profile produces a fresh merged
/// manifest, not a borrow into the parent slice. A bare name resolves to the
/// parent unmodified; a deregistered parent or a failed overlay yields `None`.
fn shell_section_of(
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    adapter_option: &str,
) -> Option<Shell> {
    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
        .ok()
        .filter(|m| m.adapter.kind == AdapterKind::Shell)
        .and_then(|m| m.shell)
}

/// Fill a `wake_command` template. The substitution catalog matches the spawn
/// template's (`id`, `adapter_name`, `link_token`) so one manifest vocabulary
/// serves both — but the waker's token is minted **unparked**: it substitutes
/// (template compatibility) without ever verifying against the perch, because
/// an offline link has no live credential by design (the close retired it; a
/// waker wakes by *exit code*, not by driving the link).
fn now_ms() -> u64 {
    crate::brain::now_ms()
}

fn fill_wake_command(
    shell_id: &str,
    adapter_name: &str,
    wake_command: &str,
) -> Result<Vec<String>, String> {
    let keys = std::collections::BTreeMap::from([
        ("id".to_string(), shell_id.to_string()),
        ("adapter_name".to_string(), adapter_name.to_string()),
        (
            "link_token".to_string(),
            crate::shellhost::mint_link_token(),
        ),
    ]);
    let filled =
        spt_runtime::runtime::fill_template(wake_command, &keys).map_err(|e| e.to_string())?;
    let tokens = spt_runtime::runtime::tokenize(&filled);
    if tokens.is_empty() {
        return Err("empty wake_command".into());
    }
    Ok(tokens)
}

/// One reconcile sweep — the invariant holder for the online/offline mutual
/// exclusivity (CONTEXT: "spt-core flips between them"):


80	            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
81	            ShellLinkError::Vocab(e) => write!(f, "{e}"),
82	            ShellLinkError::Failed(e) => write!(f, "{e}"),
83	            ShellLinkError::AlreadyOnline => {
84	                write!(f, "already online — relink is the online switch")
85	            }
86	        }
87	    }
88	}
89
90	/// Resolve a shell ref into everything a link action needs: the canonical id,
91	/// the perch record, and the registered adapter's `[shell]` section
92	/// (capabilities resolve from the manifest by `adapter_name`, never the
93	/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
94	// [impl->REQ-SHELL-1]
95	pub fn resolve_link_target(
96	    owlery: &Path,
97	    owner: &str,
98	    shell_ref: &str,
99	) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
100	    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
101	        return Err(ShellLinkError::NoShell);
102	    };
103	    // Capabilities resolve from the merged view: the stored adapter_name is the
104	    // composite option, so a profiled shell links against its overlaid [shell].
105	    let adapters_dir = spt_store::perch::adapters_dir();
106	    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
107	        .ok()
108	        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
109	        .map(|(_, m)| m);
110	    let Some(shell) = manifest.and_then(|m| m.shell) else {
111	        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
112	    };
113	    Ok((id, info, shell))
114	}
115
116	/// Drive one vocabulary-checked typed command down the durable command
117	/// channel: vocab-check → compose → MAC-stamp under the parked token's link
118	/// key → spool on the shell perch. The shared core of the local
119	/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
120	/// the caller drains per the manifest receipt.
121	// [impl->REQ-SHELL-1]
122	pub fn drive_shell(
123	    owlery: &Path,
124	    owner: &str,

            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
            ShellLinkError::Vocab(e) => write!(f, "{e}"),
            ShellLinkError::Failed(e) => write!(f, "{e}"),
            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
        }
    }
}

/// Resolve a shell ref into everything a link action needs: the canonical id,
/// the perch record, and the registered adapter's `[shell]` section
/// (capabilities resolve from the manifest by `adapter_name`, never the
/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
// [impl->REQ-SHELL-1]
pub fn resolve_link_target(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
        return Err(ShellLinkError::NoShell);
    };
    // Capabilities resolve from the merged view: the stored adapter_name is the
    // composite option, so a profiled shell links against its overlaid [shell].
    let adapters_dir = spt_store::perch::adapters_dir();
    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
        .ok()
        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
        .map(|(_, m)| m);
    let Some(shell) = manifest.and_then(|m| m.shell) else {
        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
    };
    Ok((id, info, shell))
}

/// Drive one vocabulary-checked typed command down the durable command
/// channel: vocab-check → compose → MAC-stamp under the parked token's link
/// key → spool on the shell perch. The shared core of the local
/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
/// the caller drains per the manifest receipt.
// [impl->REQ-SHELL-1]
pub fn drive_shell(
    owlery: &Path,
    owner: &str,


All three daemon seams correct (#1/#2/#3 use the appropriate disk/slice resolver, owned manifest). Now the CLI filter (#6), spawn handler (#11), and the hints surface (REQ-MANIFEST-4 — the once-per-session seen-set + skip-seen-to-next-match decision todlando flagged).


4466:            let (adapter, profile) = spt_runtime::profile::split_option(&option);
4655:                    let parent = spt_runtime::profile::split_option(&adapter).0;
4658:                        .filter(|(_, i)| spt_runtime::profile::split_option(&i.adapter_name).0 == parent)
6872:                .filter(|(_, i)| i.adapter_name == "appcap")
7013:            .all(|(_, i)| i.adapter_name != "alwsh"));


1://! Reporting + boundary surface: `api boundary`, `api session-end`,
5://! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
8://! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
34:/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
35:/// to the rotated session id, preserving identity + parent_pid anchor. A
37:/// notifs the endpoint hasn't seen surface into its spool here.
39:pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
40:    if to_session.is_empty() {
41:        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
49:    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
50:    rec.session_id = to_session.to_string();
55:    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
61:/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
69:/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each
130:            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {
146:/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
150:pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
205:/// endpoint's session, read from the daemon-stamped `driven_by` marker on the
207:/// queries it rather than guessing from session heuristics). Prints
254:/// a sensory payload to its owner's **live** session (M5-D3c, CONTEXT §Shell
371:/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
380:    let session = manifest.map(|m| &m.session);
381:    let commune = session
384:    let signoff = session
392:/// session. Only `inject` directives with a value participate (REQ-START-4).
418:/// A stable, process-independent seen-set key for one hint (hashing its
420:/// digest is deterministic across the short-lived `api hint` processes that
422:fn hint_key(h: &spt_runtime::manifest::Hint) -> String {
431:/// `api hint --session <id>` — the keyword-hint surface (REQ-MANIFEST-4,
432:/// CONTEXT §keyword hints). The full user message arrives on stdin; emit at most
433:/// ONE hint line — the first hint (declaration order) whose keyword matches AND
434:/// that this session has not seen — then mark it seen so it fires once per
435:/// session. A `/clear` mints a new `--session`, naturally re-arming every hint.
436:/// Hints ride the merged view, so a profile's `[[hints]]` overlay is what fires.
438:pub fn cmd_hint(ctx: &Ctx, session: &str) -> i32 {
440:        eprintln!("NO_MANIFEST: hint needs --manifest");
443:    if manifest.hints.is_empty() {
450:    if let Some(line) = select_and_mark_hint(manifest, session, &message) {
456:/// The once-per-session / one-per-message hint selection (the testable core of
457:/// [`cmd_hint`], stdin + stdout split out): scan `manifest.hints` in declaration
458:/// order for the first hint whose keyword matches `message` AND that `session`
459:/// has not yet seen; on a hit, mark it seen (durably) and return the formatted
460:/// line. Already-seen hits are skipped so a repeated keyword never permanently
461:/// blocks a later, distinct hint.
463:fn select_and_mark_hint(
465:    session: &str,
468:    let dir = perch::session_dir(session);
469:    let seen_file = dir.join("hints-seen");
470:    let mut seen: std::collections::HashSet<String> = std::fs::read_to_string(&seen_file)
474:    for hint in &manifest.hints {
475:        let key = hint_key(hint);
476:        if seen.contains(&key) {
477:            continue; // already fired this session
479:        if let Some(kw) = hint.matched_keyword(message) {
481:                "keyword hint for SPT adapter {}: \"{kw}\"-->{}",
482:                manifest.adapter.name, hint.text
484:            seen.insert(key);
485:            let mut rows: Vec<&str> = seen.iter().map(String::as_str).collect();
488:            let _ = spt_store::atomic::atomic_write_string(&seen_file, &rows.join("\n"));
508:    fn establish(id: &str, session: &str) {
511:        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
517:    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
523:        assert_eq!(rec.session_id, "sid-new");
537:            // seen-suppressed (delivers nothing again — dismissal is the only ack).
563:        // Second boundary: alice has seen it — nothing re-delivered.
623:    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
624:    fn session_end_soft_preserves_state() {
627:        assert_eq!(cmd_session_end("alice", false), 0);
637:    fn session_end_erase_wipes_childless_perch() {
640:        assert_eq!(cmd_session_end("alice", true), 0);
646:    fn session_end_erase_refused_with_live_children() {
650:        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
704:            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
739:    // [unit->REQ-MANIFEST-4] the hint surface: first matching hint in declaration
740:    // order fires (one per message); a hint fires once per session (the seen-set);
741:    // a new session re-arms; a non-matching message emits nothing.
743:    fn hint_once_per_session_one_per_message() {
747:             [[hints]]\nkeywords=[\"send\"]\ntext=\"use $OWL send\"\n\n\
748:             [[hints]]\nkeywords=[\"alarm\"]\ntext=\"use new-alarm\"\n",
753:        let first = select_and_mark_hint(&m, "sess-A", "how to send and set an alarm");
756:            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
758:        // Same session, same keyword again → already seen → the NEXT unseen match
759:        // (the alarm hint) fires instead; the seen 'send' hint never repeats.
760:        let second = select_and_mark_hint(&m, "sess-A", "send and alarm again");
763:            Some("keyword hint for SPT adapter claude-spt: \"alarm\"-->use new-alarm")
765:        // Both now seen this session → nothing left to fire.
766:        assert!(select_and_mark_hint(&m, "sess-A", "send an alarm").is_none());
768:        // A different session re-arms every hint (the /clear path).
770:            select_and_mark_hint(&m, "sess-B", "please send").as_deref(),
771:            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
774:        assert!(select_and_mark_hint(&m, "sess-C", "unrelated chatter").is_none());
780:            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
786:            vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]


CLI filter #6 correct (`split_option(&i.adapter_name).0 == parent` at 4658; spawn/arg splits at 4466/4655). Hint core matches CONTEXT (≤1/message via early-return, once/session via content-keyed seen-set in session_dir, /clear re-arms via new session). Two literal `adapter_name ==` compares at 6872/7013 to verify aren't production composite-blind filters, plus the Hint struct/matched_keyword (literal-vs-regex) and schema.


6862	        // refuses; nothing minted, nothing persisted.
6863	        assert_eq!(spawn("appcap"), 0);
6864	        assert_eq!(
6865	            spawn("appcap"),
6866	            1,
6867	            "beyond-cap approval is interactive-only"
6868	        );
6869	        assert_eq!(
6870	            spt_store::shellinfo::list_shells(&owlery, "doyle")
6871	                .iter()
6872	                .filter(|(_, i)| i.adapter_name == "appcap")
6873	                .count(),
6874	            1,
6875	            "the refused spawn minted nothing"

        // refuses; nothing minted, nothing persisted.
        assert_eq!(spawn("appcap"), 0);
        assert_eq!(
            spawn("appcap"),
            1,
            "beyond-cap approval is interactive-only"
        );
        assert_eq!(
            spt_store::shellinfo::list_shells(&owlery, "doyle")
                .iter()
                .filter(|(_, i)| i.adapter_name == "appcap")
                .count(),
            1,
            "the refused spawn minted nothing"


1	//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
2	//!
3	//! A manifest declares only what *varies per harness*; command templates are
4	//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
5	//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
6	//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
7	//! a human-readable message instead of panicking on bad adapter input.
8	//!
9	//! Closed vocabularies (adapter kind, history strategy, env direction, inject
10	//! method, identity source, update avenue, shell broadcast/receipt) are modeled
11	//! as enums so an unknown variant is a parse error pointing at the bad value —
12	//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
13	//!
14	//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
15	//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
16	//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
17	//
18	// [impl->REQ-MANIFEST-1]
19
20	use schemars::JsonSchema;
21	use serde::{Deserialize, Serialize};
22	use std::collections::BTreeMap;
23	use std::fmt;
24
25	/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
26	/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
27	/// adapter declares only its header.
28	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
29	pub struct Manifest {
30	    pub adapter: Adapter,
31	    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
32	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33	    pub hooks: BTreeMap<String, Hook>,
34	    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
35	    #[serde(default, skip_serializing_if = "Session::is_empty")]
36	    pub session: Session,
37	    /// `[env.<VAR>]` — env-var inject/read table.
38	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39	    pub env: BTreeMap<String, EnvVar>,
40	    #[serde(default, skip_serializing_if = "Option::is_none")]
41	    pub history: Option<History>,
42	    #[serde(default, skip_serializing_if = "Option::is_none")]
43	    pub inject: Option<Inject>,
44	    #[serde(default, skip_serializing_if = "Option::is_none")]
45	    pub identity: Option<Identity>,
46	    #[serde(default, skip_serializing_if = "Option::is_none")]
47	    pub update: Option<Update>,
48	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
49	    #[serde(default, skip_serializing_if = "Option::is_none")]
50	    pub shell: Option<Shell>,
51	    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
52	    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
53	    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
54	    /// opaque command templates). Absent ⇒ the digest is unavailable for this
55	    /// adapter.
56	    #[serde(default, skip_serializing_if = "Option::is_none")]
57	    pub pty_digest: Option<PtyDigest>,
58	    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
59	    /// overlays declared by the adapter dev inside the parent manifest, updating
60	    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]

//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
//!
//! A manifest declares only what *varies per harness*; command templates are
//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
//! a human-readable message instead of panicking on bad adapter input.
//!
//! Closed vocabularies (adapter kind, history strategy, env direction, inject
//! method, identity source, update avenue, shell broadcast/receipt) are modeled
//! as enums so an unknown variant is a parse error pointing at the bad value —
//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
//!
//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
//
// [impl->REQ-MANIFEST-1]

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
/// adapter declares only its header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Manifest {
    pub adapter: Adapter,
    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub hooks: BTreeMap<String, Hook>,
    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
    #[serde(default, skip_serializing_if = "Session::is_empty")]
    pub session: Session,
    /// `[env.<VAR>]` — env-var inject/read table.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, EnvVar>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history: Option<History>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<Identity>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update: Option<Update>,
    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
    /// overlays declared by the adapter dev inside the parent manifest, updating
    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]


6872/7013 are test fixtures (bare-name spawns), not composite-blind production filters — correct. Reading the manifest additions: profiles/strings/hints fields, `Hint::matched_keyword` (literal default + regex opt-in), and validate.


60	    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]
61	    /// merges one onto the base and re-validates the complete manifest. A bare
62	    /// `adapter_name` ignores these (parent unmodified); the composite
63	    /// `<adapter>:<profile>` selects one. **Local** (node-local, user-authored)
64	    /// profiles live beside the adapter in the registry, never here.
65	    /// (CONTEXT.md §adapter profile.)
66	    // [impl->REQ-MANIFEST-2]
67	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
68	    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
69	    pub profiles: BTreeMap<String, toml::Value>,
70	    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core
71	    /// never executes a string; command templates live in their own sections
72	    /// behind registration). Dot-path-readable via `spt adapter get-string`, and
73	    /// it rides the same leaf-replace profile overlay as the rest of the manifest
74	    /// (a shipped or local profile may override base strings). Node-local; no
75	    /// cross-node sync. (CONTEXT.md §adapter strings.)
76	    // [impl->REQ-MANIFEST-3]
77	    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
78	    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
79	    pub strings: toml::Table,
80	    /// `[[hints]]` — once-per-session keyword hints (CONTEXT.md §keyword hints).
81	    /// **Order is significant** (first match wins). A profile overlays this by
82	    /// leaf-replace like any section — the array is replaced wholesale, never
83	    /// spliced (override/extend = re-declare).
84	    // [impl->REQ-MANIFEST-4]
85	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86	    pub hints: Vec<Hint>,
87	}
88
89	/// `[adapter]` — the manifest header, readable before any update (compat gate).
90	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
91	pub struct Adapter {
92	    pub name: String,
93	    #[serde(default)]
94	    pub kind: AdapterKind,
95	    pub version: String,
96	    /// Lowest spt-core version this adapter tolerates (compat gate).
97	    pub min_spt_core_version: String,
98	    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
99	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100	    pub hostable_types: Vec<String>,
101	}
102
103	/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
104	/// surface (MANIFEST §Shell adapters).
105	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
106	#[serde(rename_all = "snake_case")]
107	pub enum AdapterKind {
108	    #[default]
109	    Harness,
110	    Shell,
111	}
112
113	/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
114	/// stdin fields it maps, and whether it can surface context to the agent.
115	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
116	pub struct Hook {
117	    /// Opaque `api …` command line the harness invokes for this event.
118	    pub fires: String,
119	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120	    pub reads: Vec<String>,
121	    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
122	    #[serde(default)]
123	    pub can_inject: bool,
124	}
125
126	/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).
127	/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;
128	/// a matching keyword surfaces `text` to the agent's context channel, at most
129	/// once per session and once per message.
130	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
131	pub struct Hint {
132	    /// Keywords that fire the hint — literal **case-insensitive substrings** by
133	    /// default; compiled as **regex** patterns when `regex = true`.
134	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135	    pub keywords: Vec<String>,
136	    /// The hint text surfaced when a keyword matches.
137	    pub text: String,
138	    /// Treat `keywords` as regex patterns instead of literal substrings.
139	    #[serde(default)]
140	    pub regex: bool,
141	}
142
143	impl Hint {
144	    /// The first keyword that fires against `message` (literal case-insensitive
145	    /// substring, or regex when `regex = true`); `None` if none match. An invalid
146	    /// regex never matches — a bad pattern silences its own hint, never panics
147	    /// (best-effort, the opaque-template stance).
148	    // [impl->REQ-MANIFEST-4]
149	    pub fn matched_keyword(&self, message: &str) -> Option<&str> {
150	        let lowered = message.to_lowercase();
151	        self.keywords
152	            .iter()
153	            .find(|kw| {
154	                if self.regex {
155	                    regex::Regex::new(kw)
156	                        .map(|re| re.is_match(message))
157	                        .unwrap_or(false)
158	                } else {
159	                    lowered.contains(&kw.to_lowercase())
160	                }
161	            })
162	            .map(String::as_str)
163	    }
164	}
165
166	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
167	/// with the fixed set of `[session.<role>]` command templates.
168	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
169	pub struct Session {
170	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
171	    // only the directory is adapter-declared (MANIFEST §watched dirs).
172	    #[serde(default, skip_serializing_if = "Option::is_none")]
173	    pub commune_dir: Option<String>,
174	    #[serde(default, skip_serializing_if = "Option::is_none")]
175	    pub signoff_dir: Option<String>,
176	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
177	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
178	    // *executed* in M2b.
179	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
180	    pub self_: Option<SessionRole>,
181	    #[serde(default, skip_serializing_if = "Option::is_none")]
182	    pub psyche_init: Option<SessionRole>,
183	    #[serde(default, skip_serializing_if = "Option::is_none")]
184	    pub psyche_resume: Option<SessionRole>,
185	    #[serde(default, skip_serializing_if = "Option::is_none")]
186	    pub echo_commune: Option<SessionRole>,
187	    #[serde(default, skip_serializing_if = "Option::is_none")]
188	    pub signoff: Option<SessionRole>,
189	    /// `[session.notif]` — the endpoint-native notification render

    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]
    /// merges one onto the base and re-validates the complete manifest. A bare
    /// `adapter_name` ignores these (parent unmodified); the composite
    /// `<adapter>:<profile>` selects one. **Local** (node-local, user-authored)
    /// profiles live beside the adapter in the registry, never here.
    /// (CONTEXT.md §adapter profile.)
    // [impl->REQ-MANIFEST-2]
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
    pub profiles: BTreeMap<String, toml::Value>,
    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core
    /// never executes a string; command templates live in their own sections
    /// behind registration). Dot-path-readable via `spt adapter get-string`, and
    /// it rides the same leaf-replace profile overlay as the rest of the manifest
    /// (a shipped or local profile may override base strings). Node-local; no
    /// cross-node sync. (CONTEXT.md §adapter strings.)
    // [impl->REQ-MANIFEST-3]
    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
    pub strings: toml::Table,
    /// `[[hints]]` — once-per-session keyword hints (CONTEXT.md §keyword hints).
    /// **Order is significant** (first match wins). A profile overlays this by
    /// leaf-replace like any section — the array is replaced wholesale, never
    /// spliced (override/extend = re-declare).
    // [impl->REQ-MANIFEST-4]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hints: Vec<Hint>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Adapter {
    pub name: String,
    #[serde(default)]
    pub kind: AdapterKind,
    pub version: String,
    /// Lowest spt-core version this adapter tolerates (compat gate).
    pub min_spt_core_version: String,
    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostable_types: Vec<String>,
}

/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
/// surface (MANIFEST §Shell adapters).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    #[default]
    Harness,
    Shell,
}

/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
/// stdin fields it maps, and whether it can surface context to the agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Hook {
    /// Opaque `api …` command line the harness invokes for this event.
    pub fires: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reads: Vec<String>,
    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
    #[serde(default)]
    pub can_inject: bool,
}

/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).
/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;
/// a matching keyword surfaces `text` to the agent's context channel, at most
/// once per session and once per message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Hint {
    /// Keywords that fire the hint — literal **case-insensitive substrings** by
    /// default; compiled as **regex** patterns when `regex = true`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keywords: Vec<String>,
    /// The hint text surfaced when a keyword matches.
    pub text: String,
    /// Treat `keywords` as regex patterns instead of literal substrings.
    #[serde(default)]
    pub regex: bool,
}

impl Hint {
    /// The first keyword that fires against `message` (literal case-insensitive
    /// substring, or regex when `regex = true`); `None` if none match. An invalid
    /// regex never matches — a bad pattern silences its own hint, never panics
    /// (best-effort, the opaque-template stance).
    // [impl->REQ-MANIFEST-4]
    pub fn matched_keyword(&self, message: &str) -> Option<&str> {
        let lowered = message.to_lowercase();
        self.keywords
            .iter()
            .find(|kw| {
                if self.regex {
                    regex::Regex::new(kw)
                        .map(|re| re.is_match(message))
                        .unwrap_or(false)
                } else {
                    lowered.contains(&kw.to_lowercase())
                }
            })
            .map(String::as_str)
    }
}

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render


Hint impl clean — literal case-insensitive substring default, regex opt-in, invalid regex silences its own hint (no panic). Now the REQ registry additions + schema, then independent verification (traceable check + tests) to confirm todlando's GREEN claim.


Bash

git diff d338c2d..0f34daa -- traceable-reqs.toml

REQ registry additions


diff --git a/traceable-reqs.toml b/traceable-reqs.toml
index 8c9c5a0..5137aa4 100644
--- a/traceable-reqs.toml
+++ b/traceable-reqs.toml
@@ -69,6 +69,18 @@ id = "REQ-MANIFEST-1"
 title = "Per-adapter manifest with adapter_name and min_spt_core_version"
 required_stages = ["doc", "impl", "unit"]
 [[requirements]]
+id = "REQ-MANIFEST-2"
+title = "Adapter profiles — sparse leaf-replace overlays (shipped + local), composite <adapter>:<profile> addressing, shadow-refusal, tighten-only consent floors"
+required_stages = ["doc", "impl", "unit"]
+[[requirements]]
+id = "REQ-MANIFEST-3"
+title = "Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)"
+required_stages = ["doc", "impl", "unit"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)
+[[requirements]]
+id = "REQ-MANIFEST-4"
+title = "Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace"
+required_stages = ["doc", "impl", "unit"]  # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set)
+[[requirements]]
 id = "REQ-SEAM-SPAWN"
 title = "spawn-session seam"
 required_stages = ["impl", "unit"]


git diff d338c2d..0f34daa -- crates/spt-runtime/manifest.schema.json | head -80

Schema additions for profiles/strings/hints


diff --git a/crates/spt-runtime/manifest.schema.json b/crates/spt-runtime/manifest.schema.json
index 50301a4..3bd4c7c 100644
--- a/crates/spt-runtime/manifest.schema.json
+++ b/crates/spt-runtime/manifest.schema.json
@@ -86,6 +86,23 @@
           "type": "null"
         }
       ]
+    },
+    "profiles": {
+      "description": "`[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace\noverlays declared by the adapter dev inside the parent manifest, updating\nas one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]\nmerges one onto the base and re-validates the complete manifest. A bare\n`adapter_name` ignores these (parent unmodified); the composite\n`<adapter>:<profile>` selects one. **Local** (node-local, user-authored)\nprofiles live beside the adapter in the registry, never here.\n(CONTEXT.md §adapter profile.)",
+      "type": "object",
+      "additionalProperties": true
+    },
+    "strings": {
+      "description": "`[strings]` — an adapter-authored KV tree of **opaque data** (spt-core\nnever executes a string; command templates live in their own sections\nbehind registration). Dot-path-readable via `spt adapter get-string`, and\nit rides the same leaf-replace profile overlay as the rest of the manifest\n(a shipped or local profile may override base strings). Node-local; no\ncross-node sync. (CONTEXT.md §adapter strings.)",
+      "type": "object",
+      "additionalProperties": true
+    },
+    "hints": {
+      "description": "`[[hints]]` — once-per-session keyword hints (CONTEXT.md §keyword hints).\n**Order is significant** (first match wins). A profile overlays this by\nleaf-replace like any section — the array is replaced wholesale, never\nspliced (override/extend = re-declare).",
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/Hint"
+      }
     }
   },
   "required": [
@@ -714,6 +731,31 @@
         "input_pattern",
         "agent_pattern"
       ]
+    },
+    "Hint": {
+      "description": "`[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).\nThe adapter's user-prompt hook pipes the full user message to `spt api hint`;\na matching keyword surfaces `text` to the agent's context channel, at most\nonce per session and once per message.",
+      "type": "object",
+      "properties": {
+        "keywords": {
+          "description": "Keywords that fire the hint — literal **case-insensitive substrings** by\ndefault; compiled as **regex** patterns when `regex = true`.",
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "text": {
+          "description": "The hint text surfaced when a keyword matches.",
+          "type": "string"
+        },
+        "regex": {
+          "description": "Treat `keywords` as regex patterns instead of literal substrings.",
+          "type": "boolean",
+          "default": false
+        }
+      },
+      "required": [
+        "text"
+      ]
     }
   },
   "$id": "https://sabermage.github.io/spt-releases/manifest.schema.json"


REQ registry + schema clean (Hint `text` required, keywords/regex optional with sane defaults). Now independent verification — running traceable-reqs check + the affected crate tests in background to confirm GREEN, staying reachable.


cd C:/Users/decid/Documents/projects/spt-core && cargo run -q -p xtask -- traceable check 2>&1 | tail -20; echo "EXIT:$?"

Run traceable-reqs check


cd C:/Users/decid/Documents/projects/spt-core && cargo test -q -p spt-runtime -p spt-store 2>&1 | tail -30; echo "EXIT:$?"

Run spt-runtime + spt-store tests


Command running in background with ID: br2rnyjsd. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\br2rnyjsd.output. You will be notified when it completes. To check interim output, use Read on that file path.


Command running in background with ID: b7pcps5gp. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\b7pcps5gp.output. You will be notified when it completes. To check interim output, use Read on that file path.


While verification runs, checking the CLI verb shape (create-profile/delete-profile/get-string/set-string + list render) and the `api hint` stdin wiring — both on the G1 checklist.