1	//! Harness-instance hosting (M12-W1 — spt-hosted harness bringup, REQ-HOST-RUN-1):
2	//! the spt-HOSTED counterpart to harness-hosted launch.
3	//!
4	//! **Who spawns the harness.** Today a harness is *harness-hosted*: an external
5	//! launcher runs the adapter's `[session.self]` command and the harness binds its
6	//! own perch on startup (`api listen`/`api bind` → `establish_perch`). spt-hosting
7	//! changes *only who owns the PTY*: the daemon spawns `[session.self]` into a
8	//! **broker-held PTY** ([`launch_harness_brokered_in`]). The harness still
9	//! self-registers its perch on bind — there is no daemon pre-register step.
10	//!
11	//! **Why broker-held.** The broker owns the PTY, so the harness survives a
12	//! `spt rc` detach — PTY ownership stays with the broker, no termination on
13	//! detach (the KNOWN-HAZARDS attach-lifecycle invariant, satisfied by
14	//! construction). The broker session is labeled with the endpoint id so
15	//! `spt rc <id>` resolves exactly this PTY.
16	//!
17	//! **Id plumbing.** The endpoint id reaches the harness two ways, both filled
18	//! from the SAME `{key}` catalog: argv via `{id}` substitution into the opaque
19	//! `[session.self]` command (the harness parses `--id`), AND the manifest
20	//! `[env.<VAR>]` direction = inject values (e.g. `SPT_ENDPOINT_ID = "{id}"`),
21	//! which are `{key}`-substituted and passed in `SpawnReq.env` so the broker sets
22	//! them on the spawned child (REQ-HAZARD-ENV-SUBST). A flagless harness whose
23	//! `[session.self]` has no argv slot for `{id}` (e.g. bare `claude`) relies on
24	//! the env inject — which previously arrived EMPTY (the `[env]` value was never
25	//! substituted nor applied), so the harness seeded-by-PPID instead of binding →
26	//! a zero-perch `NO_PERCH`. This mirrors [`crate::shellhost::launch_shell_brokered_in`]
27	//! but feeds `[session.self]` (not `[shell].spawn`), uses real terminal PTY dims,
28	//! and mints no link token (a harness binds via `api`, not the shell link
29	//! handshake).
30	// [impl->REQ-HOST-RUN-1]
31
32	use std::collections::BTreeMap;
33
34	use spt_runtime::manifest::{AdapterKind, EnvDirection, Manifest};
35
36	use crate::brain::{now_ms, Brain};
37	use crate::msg::SpawnReq;
38
39	/// Default PTY geometry for a freshly brought-up harness, before an attach
40	/// resizes it to the operator's real terminal. A harness *is* a terminal (unlike
41	/// a shell's wide anti-wrap stdin conduit), so a conventional 80×24 is right.
42	pub const DEFAULT_HARNESS_ROWS: u16 = 24;
43	pub const DEFAULT_HARNESS_COLS: u16 = 80;
44
45	/// Outcome of an spt-hosted harness bringup: the broker session id (the attach
46	/// handle) plus the OS pid of the spawned harness (`None` if the broker backend
47	/// exposed none).
48	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49	pub struct HarnessSpawned {
50	    pub session_id: u64,
51	    pub pid: Option<u32>,
52	}
53
54	/// Mint a provisional spawn-time session id (16 hex chars). The *real* session id
55	/// is reported post-spawn by the harness (`identity.session_id_source =
56	/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
57	/// command template resolves. A fresh create mints one; `--resume` supplies the
58	/// prior session id instead.
59	pub fn mint_session_id() -> String {
60	    let seed = spt_proto::identity::Identity::generate().seed();
61	    seed[..8].iter().map(|b| format!("{b:02x}")).collect()
62	}
63
64	/// Validate the adapter and fill its session command for one endpoint, returning
65	/// the tokenized `program + args`. Pure (no spawn) — the broker call in
66	/// [`launch_harness_brokered_in`] is the only side effect. Fails closed: a
67	/// non-harness adapter, a missing role command, an unresolved `{placeholder}`,
68	/// or an empty command all error before anything is spawned.
69	///
70	/// `is_resume`: select the NATIVE-resume role over the fresh one — when `true`
71	/// AND the adapter declares `[session.resume]`, the resume template is filled
72	/// (the harness runs e.g. `claude -r {session_id}` instead of a fresh
73	/// `claude --session-id {session_id}`, which would start a blank transcript). A
74	/// `true` with NO `[session.resume]` declared FALLS BACK to `[session.self]`
75	/// (full back-compat); a `false` always uses `[session.self]`.
76	// [impl->REQ-HOST-RUN-1]
77	// [impl->REQ-SESSION-RESUME-TEMPLATE]
78	pub fn prepare_harness_spawn(
79	    endpoint_id: &str,
80	    adapter_name: &str,
81	    session_id: &str,
82	    manifest: &Manifest,
83	    is_resume: bool,
84	) -> Result<PreparedSpawn, String> {
85	    if manifest.adapter.kind != AdapterKind::Harness {
86	        return Err(format!(
87	            "adapter '{adapter_name}' is kind={:?}, not a harness — \
88	             `spt endpoint run` brings up harness endpoints",
89	            manifest.adapter.kind
90	        ));
91	    }
92	    // Resume selects `[session.resume]` when declared, else falls back to
93	    // `[session.self]` (an adapter with no resume sibling behaves exactly as
94	    // today). A fresh bringup always uses `[session.self]`.
95	    let role = if is_resume {
96	        manifest
97	            .session
98	            .resume
99	            .as_ref()
100	            .or(manifest.session.self_.as_ref())
101	    } else {
102	        manifest.session.self_.as_ref()
103	    }
104	    .ok_or_else(|| {
105	        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
106	    })?;
107	    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
108	    // binds to exactly this endpoint; `session_name` defaults to the id. The SAME
109	    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
110	    let keys = BTreeMap::from([
111	        ("id".to_string(), endpoint_id.to_string()),
112	        ("adapter_name".to_string(), adapter_name.to_string()),
113	        ("session_id".to_string(), session_id.to_string()),
114	        ("session_name".to_string(), endpoint_id.to_string()),
115	    ]);
116	    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
117	    // token so a multi-word/quote/semicolon {key} value is one argv element.
118	    let tokens =
119	        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
120	    if tokens.is_empty() {
121	        return Err("empty session command".into());
122	    }
123	    // [impl->REQ-HAZARD-ENV-SUBST] every [env.<VAR>] direction=inject value is
124	    // {key}-substituted from the SAME catalog and passed to the broker for the
125	    // spawned child's environment — the schema's "Value to inject (with
126	    // substitution)" promise, previously unfulfilled (the value arrived empty, so
127	    // a flagless harness seeded-by-PPID instead of binding → zero perch).
128	    let mut env = BTreeMap::new();
129	    for (name, var) in &manifest.env {
130	        if var.direction == EnvDirection::Inject {
131	            // Validation guarantees an inject directive has a value; fill it.
132	            if let Some(template) = &var.value {
133	                let filled = spt_runtime::runtime::fill_template(template, &keys)
134	                    .map_err(|e| e.to_string())?;
135	                env.insert(name.clone(), filled);
136	            }
137	        }
138	    }
139	    // ADR-0022 / W2: carry the adapter's idle-delivery translation-binary path
140	    // (if declared) so the broker can lifecycle-manage it alongside this session.
141	    // The path is opaque (resolved at spawn like every adapter command).
142	    let translation_binary = manifest
143	        .message_idle_translation_binary
144	        .as_ref()
145	        .map(|b| b.path.clone());
146	    Ok(PreparedSpawn {
147	        tokens,
148	        env,
149	        translation_binary,
150	    })
151	}
152
153	/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
154	/// `[env]`-inject map (both filled from the same `{key}` catalog —
155	/// REQ-HAZARD-ENV-SUBST), and the optional idle-delivery translation-binary path
156	/// (ADR-0022) the broker lifecycle-manages.

... [274 lines truncated] ...


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

... [84 lines truncated] ...


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_messages>
<owl_message from="todlando-w181" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: todlando-w181
Agent type: spt-test-engineer
To message it: $OWL send todlando-w181 todlando</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.

<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Tests green per subagent. Independent verification before surfacing (spt-verification-gates: workspace clippy CI form + traceable + real bin rebuild):


Bash

echo "=== CLIPPY (CI form) ===" && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -8 && echo "=== TRACEABLE ===" && traceable-reqs check 2>&1 | tail -15 && echo "=== BUILD REAL BIN ===" && cargo build -p spt --bin spt 2>&1 | tail -5

Independent gates: clippy + traceable + real bin


Command running in background with ID: byty6skd3. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\97787e5e-64b2-4afd-b3fe-becaa2ea8030\tasks\byty6skd3.output. You will be notified when it completes. To check interim output, use Read on that file path.


Gates running. Reviewing the subagent's new int test + mock extension (I gate-review before commit):


1	//! v0.13.0 P2 (REQ-SESSION-RESUME-TEMPLATE) — the real-fixture gate for the
2	//! resume-blank ship-blocker. Root cause: spt-core had `[session.self]` (fresh)
3	//! but no `[session.resume]` native-resume sibling, so `--resume` re-ran the
4	//! FRESH command → the harness started a blank transcript.
5	//!
6	//! This drives the SAME real spt-hosted bringup as `dummy_harness_e2e.rs` —
7	//! `spt endpoint run --start` → broker PTY → the dummy harness — against a REAL
8	//! detached `spt daemon run`, with a dummyharness adapter whose `[session.resume]`
9	//! command DIFFERS from `[session.self]`. The dummy writes a MARKER file recording
10	//! (a) which template launched it (`self` vs `resume`) and (b) the `current_dir()`
11	//! it landed in. We assert:
12	//!   - a FRESH `endpoint run --start` (no `--resume`) writes the SELF marker, and
13	//!     the RESUME marker is ABSENT;
14	//!   - a `--resume <session>` `endpoint run --start` writes the RESUME marker
15	//!     (proving template SELECTION), and the recorded cwd matches the cwd the
16	//!     bringup landed in (proving the cwd plumbing).
17	//!
18	//! Both bringups use `--start` (detached, return-now) — no attach pump — so the
19	//! marker file is the deterministic proof, with no PTY-output capture race. The
20	//! marker is written into the isolated SPT_HOME (always writable regardless of
21	//! the harness cwd); its CONTENTS carry the harness `current_dir()` so the cwd
22	//! assertion is independent of where the marker file itself lives.
23	//!
24	//! Bounded; all spawned pids reaped SCOPED (never machine-wide — shared runner).
25	//! [int->REQ-SESSION-RESUME-TEMPLATE]
26
27	use std::path::{Path, PathBuf};
28	use std::process::{Child, Command, Output, Stdio};
29	use std::time::{Duration, Instant};
30
31	mod common;
32	use common::CommandNoWindowExt;
33
34	use spt_store::perch::{self, ParentHint};
35
36	fn kill_pid(pid: u32) {
37	    #[cfg(windows)]
38	    let _ = Command::new("taskkill")
39	        .no_window()
40	        .args(["/PID", &pid.to_string(), "/F", "/T"])
41	        .output();
42	    #[cfg(unix)]
43	    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
44	}
45
46	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
47	    let (tx, rx) = std::sync::mpsc::channel();
48	    std::thread::spawn(move || {
49	        let _ = tx.send(cmd.output());
50	    });
51	    rx.recv_timeout(deadline)
52	        .expect("captured spt call must complete within its deadline")
53	        .expect("run spt")
54	}
55
56	fn ready_pid(path: &Path) -> Option<u32> {
57	    let s = std::fs::read_to_string(path).ok()?;
58	    serde_json::from_str::<serde_json::Value>(&s)
59	        .ok()?
60	        .get("pid")?
61	        .as_u64()
62	        .map(|p| p as u32)
63	}
64
65	fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
66	    let deadline = Instant::now() + budget;
67	    while Instant::now() < deadline {
68	        if let Some(p) = ready_pid(path) {
69	            return Some(p);
70	        }
71	        std::thread::sleep(Duration::from_millis(50));
72	    }
73	    None
74	}
75
76	fn sibling_bin(name: &str) -> PathBuf {
77	    PathBuf::from(env!("CARGO_BIN_EXE_spt"))
78	        .with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
79	}
80
81	/// Poll for a marker file to appear (the dummy writes it before its heartbeat
82	/// loop), bounded. Returns its parsed `(template, cwd)` lines, or None on timeout.
83	fn wait_for_marker(path: &Path, budget: Duration) -> Option<(String, String)> {
84	    let deadline = Instant::now() + budget;
85	    while Instant::now() < deadline {
86	        if let Ok(s) = std::fs::read_to_string(path) {
87	            let mut template = None;
88	            let mut cwd = None;
89	            for line in s.lines() {
90	                if let Some(v) = line.strip_prefix("template=") {
91	                    template = Some(v.to_string());
92	                }
93	                if let Some(v) = line.strip_prefix("cwd=") {
94	                    cwd = Some(v.to_string());
95	                }
96	            }
97	            if let (Some(t), Some(c)) = (template, cwd) {
98	                return Some((t, c));
99	            }
100	        }
101	        std::thread::sleep(Duration::from_millis(50));
102	    }
103	    None
104	}
105
106	#[test]
107	fn endpoint_run_resume_selects_resume_template_in_recorded_cwd() {
108	    // ── (1) Isolated SPT_HOME. ──
109	    let home = tempfile::tempdir().unwrap();
110	    std::env::set_var("SPT_HOME", home.path());
111
112	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
113	    let mock_session = sibling_bin("mock-session");
114	    assert!(
115	        mock_session.exists(),
116	        "the dummy-harness program must be built: {} \
117	         (pre-build: cargo build -p mock-adapter --bin mock-session)",
118	        mock_session.display()
119	    );
120
121	    // Distinct marker paths under the isolated home — always writable regardless
122	    // of the harness cwd. The marker CONTENTS carry the harness current_dir(), so
123	    // the cwd assertion is independent of where the marker file lives.
124	    let markers = home.path().join("markers");
125	    std::fs::create_dir_all(&markers).unwrap();
126	    let self_marker = markers.join("self.marker");
127	    let resume_marker = markers.join("resume.marker");
128	    let self_marker_for_cmd = self_marker.to_string_lossy().replace('\\', "/");
129	    let resume_marker_for_cmd = resume_marker.to_string_lossy().replace('\\', "/");
130
131	    // ── (2) Register the dummyharness adapter with a [session.self] that writes a
132	    //    SELF marker and a DISTINCT [session.resume] that writes a RESUME marker
133	    //    (plus the `--resume-marker` token the dummy stamps into the marker). Both
134	    //    keep the long-lived dummy heartbeating + binding its perch, so the real
135	    //    broker-PTY bringup path runs end-to-end. ──
136	    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
137	    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
138	    let self_cmd = format!(
139	        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
140	         --spt {spt_for_cmd} --mock-adapter dummyresume --type live_agent --heartbeat-ms 200 \
141	         --marker-file {self_marker_for_cmd}"
142	    );
143	    let resume_cmd = format!(
144	        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
145	         --spt {spt_for_cmd} --mock-adapter dummyresume --type live_agent --heartbeat-ms 200 \
146	         --marker-file {resume_marker_for_cmd} --resume-marker"
147	    );
148
149	    let exe_suffix = spt_bin
150	        .extension()
151	        .map(|e| format!(".{}", e.to_string_lossy()))
152	        .unwrap_or_default();
153	    let src = perch::spt_home().join("srcs").join("dummyresume");
154	    std::fs::create_dir_all(&src).unwrap();
155	    // A long-lived psyche fixture so the brain's reconcile marks status=online
156	    // (cmd_bind gates online to live_agent && psyche_init.is_some()).
157	    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
158	    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
159	    let drops = home.path().join("drops");
160	    std::fs::create_dir_all(&drops).unwrap();
161	    let dir = drops.to_string_lossy().replace('\\', "/");
162	    let manifest_toml = format!(
163	        "[adapter]\nname=\"dummyresume\"\nkind=\"harness\"\nversion=\"1\"\n\
164	         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\nkeys=[\"id\",\"session_id\"]\n\n\
165	         [session.resume]\ncommand='{resume_cmd}'\nkeys=[\"id\",\"session_id\"]\n\n\
166	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
167	         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
168	    );
169	    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
170	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
171
172	    // ── (3) The REAL daemon broker process. ──
173	    let brain_log = home.path().join("brain.stderr.log");
174	    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
175	    let mut broker: Child = Command::new(&spt_bin)
176	        .no_window()
177	        .args(["daemon", "run"])
178	        .env("SPT_HOME", home.path())
179	        .stdout(Stdio::null())
180	        .stderr(Stdio::from(brain_log_file))
181	        .spawn()
182	        .expect("spawn spt daemon run (broker process)");
183	    let brain_pid =
184	        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
185	            Some(p) => p,
186	            None => {
187	                let _ = broker.kill();
188	                let _ = broker.wait();
189	                panic!(
190	                    "PRECONDITION: brain never came up.\n{}",
191	                    std::fs::read_to_string(&brain_log).unwrap_or_default()
192	                );
193	            }
194	        };
195
196	    // ── (4) FRESH bringup: `endpoint run --start` with NO --resume → the harness
197	    //    runs [session.self] → SELF marker present, RESUME marker ABSENT. ──
198	    let id = "dummyresume1";
199	    let fresh_run = {
200	        let mut cmd = Command::new(&spt_bin);
201	        cmd.no_window()
202	            .args(["endpoint", "run", "--adapter", "dummyresume", "--id", id, "--start"])
203	            .env("SPT_HOME", home.path());
204	        output_bounded(cmd, Duration::from_secs(45))
205	    };
206	    let fresh_stderr = String::from_utf8_lossy(&fresh_run.stderr).to_string();
207	    let harness_pid_1: Option<u32> = fresh_stderr
208	        .split("pid=")
209	        .nth(1)
210	        .and_then(|s| s.split_whitespace().next())
211	        .and_then(|s| s.trim().parse().ok());
212
213	    // The fresh harness writes its SELF marker shortly after spawn.
214	    let self_seen = wait_for_marker(&self_marker, Duration::from_secs(20));
215	    // The launch cwd the broker inherited (the test process cwd) — what the
216	    // harness current_dir() should equal (the W1 --start shortcut inherits it).
217	    let launch_cwd = std::env::current_dir()
218	        .ok()
219	        .map(|p| p.to_string_lossy().into_owned());
220
221	    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
222	    let status = || spt_store::info::read_info(&self_perch).and_then(|i| i.status);
223	    let mut online_1 = false;
224	    let deadline = Instant::now() + Duration::from_secs(20);
225	    while Instant::now() < deadline {
226	        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
227	            online_1 = true;
228	            break;
229	        }
230	        std::thread::sleep(Duration::from_millis(150));
231	    }
232
233	    // Record whether the RESUME marker exists BEFORE the resume run (must not).
234	    let resume_marker_before = resume_marker.exists();
235
236	    // ── (5) Drive the endpoint offline (kill the fresh harness), wait OFFLINE, so
237	    //    the resume re-bringup is a clean second spawn (not racing the first). ──
238	    if let Some(p) = harness_pid_1 {
239	        kill_pid(p);
240	    }
241	    let mut went_offline = false;
242	    let off_deadline = Instant::now() + Duration::from_secs(15);
243	    while Instant::now() < off_deadline {
244	        if status().as_deref() == Some(spt_store::liveness::STATUS_OFFLINE) {
245	            went_offline = true;
246	            break;
247	        }
248	        std::thread::sleep(Duration::from_millis(150));
249	    }
250
251	    // ── (6) RESUME bringup: `endpoint run --resume <session> --start`. is_resume
252	    //    selects [session.resume] → RESUME marker present, recording current_dir().
253	    //    The session id is arbitrary (the dummy just echoes it) — what matters is
254	    //    that --resume flips template selection and the cwd plumbing lands. ──
255	    let resumed_session = "resumed-session-xyz";
256	    let resume_run = {
257	        let mut cmd = Command::new(&spt_bin);
258	        cmd.no_window()
259	            .args([
260	                "endpoint", "run", "--adapter", "dummyresume", "--id", id, "--resume",
261	                resumed_session, "--start",
262	            ])
263	            .env("SPT_HOME", home.path());
264	        output_bounded(cmd, Duration::from_secs(45))
265	    };
266	    let resume_stderr = String::from_utf8_lossy(&resume_run.stderr).to_string();
267	    let harness_pid_2: Option<u32> = resume_stderr
268	        .split("pid=")
269	        .nth(1)
270	        .and_then(|s| s.split_whitespace().next())
271	        .and_then(|s| s.trim().parse().ok());
272
273	    let resume_seen = wait_for_marker(&resume_marker, Duration::from_secs(20));
274
275	    // ── (7) Reap the ENTIRE tree SCOPED before asserting (never leave a stray
276	    //    spt.exe — a survivor locks target\debug\spt.exe). ──
277	    if let Some(p) = harness_pid_1 {
278	        kill_pid(p);
279	    }
280	    if let Some(p) = harness_pid_2 {
281	        kill_pid(p);
282	    }
283	    let psyche_perch = perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
284	    if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
285	        kill_pid(p);
286	    }
287	    let _ = {
288	        let mut cmd = Command::new(&spt_bin);
289	        cmd.no_window()
290	            .args(["daemon", "stop"])
291	            .env("SPT_HOME", home.path());
292	        output_bounded(cmd, Duration::from_secs(20))
293	    };
294	    kill_pid(brain_pid);
295	    let _ = broker.kill();
296	    let _ = broker.wait();
297	    let reap_deadline = Instant::now() + Duration::from_secs(10);
298	    while Instant::now() < reap_deadline {
299	        let brain_gone = !spt_store::proc::is_process_alive(brain_pid);
300	        let h1_gone = harness_pid_1
301	            .map(|p| !spt_store::proc::is_process_alive(p))
302	            .unwrap_or(true);
303	        let h2_gone = harness_pid_2
304	            .map(|p| !spt_store::proc::is_process_alive(p))
305	            .unwrap_or(true);
306	        if brain_gone && h1_gone && h2_gone {
307	            break;
308	        }
309	        std::thread::sleep(Duration::from_millis(100));
310	    }
311
312	    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
313	    eprintln!(
314	        "=== DIAGNOSTIC (RESUME-TEMPLATE): fresh_ok={} online_1={online_1} \
315	         went_offline={went_offline} resume_ok={} resume_before={resume_marker_before} \
316	         self_seen={self_seen:?} resume_seen={resume_seen:?} launch_cwd={launch_cwd:?} ===\n\
317	         === fresh stderr ===\n{fresh_stderr}\n=== resume stderr ===\n{resume_stderr}\n\
318	         === brain stderr ===\n{brain_stderr}",
319	        fresh_run.status.success(),
320	        resume_run.status.success(),
321	    );
322
323	    // ── (8) THE GATES. ──
324	    assert!(
325	        fresh_run.status.success(),
326	        "fresh endpoint run --start must succeed: {fresh_stderr}"
327	    );
328	    assert!(
329	        online_1,
330	        "PRECONDITION: the fresh bringup must bind ONLINE via the real broker PTY.\n\
331	         === brain stderr ===\n{brain_stderr}"
332	    );
333
334	    // FRESH → SELF template selected; RESUME marker absent before the resume run.
335	    let (self_template, _self_cwd) = self_seen.expect(
336	        "the FRESH bringup (no --resume) must run [session.self] and write the SELF marker — \
337	         template selection failed",
338	    );
339	    assert_eq!(
340	        self_template, "self",
341	        "a fresh bringup must run the [session.self] template"
342	    );
343	    assert!(
344	        !resume_marker_before,
345	        "the RESUME marker must be ABSENT after a fresh (non-resume) bringup — the fresh \
346	         path must NOT run [session.resume]"
347	    );
348
349	    assert!(
350	        resume_run.status.success(),
351	        "resume endpoint run --start must succeed: {resume_stderr}"
352	    );
353
354	    // RESUME → the [session.resume] template was selected (the ship-blocker fix:
355	    // --resume no longer re-runs the FRESH command).
356	    let (resume_template, resume_cwd) = resume_seen.expect(
357	        "the --resume bringup must run [session.resume] and write the RESUME marker — \
358	         the resume-template SELECTION (REQ-SESSION-RESUME-TEMPLATE) failed; pre-fix --resume \
359	         re-ran [session.self] → blank transcript",
360	    );
361	    assert_eq!(
362	        resume_template, "resume",
363	        "a --resume bringup must run the [session.resume] template, NOT [session.self]"
364	    );
365
366	    // The cwd plumbing: the resumed harness landed in the launch cwd (the W1
367	    // --start shortcut inherits the broker's cwd, which inherits this test
368	    // process's cwd). Compare canonicalized to absorb path-form differences
369	    // (8.3 short names, symlinked temp dirs, trailing separators).
370	    if let Some(expected) = launch_cwd {
371	        let got = std::path::Path::new(&resume_cwd);
372	        let want = std::path::Path::new(&expected);
373	        let got_c = got.canonicalize().unwrap_or_else(|_| got.to_path_buf());
374	        let want_c = want.canonicalize().unwrap_or_else(|_| want.to_path_buf());
375	        assert_eq!(
376	            got_c, want_c,
377	            "the resumed harness must land in the recorded/launch cwd \
378	             (got {resume_cwd:?}, want {expected:?}) — the cwd plumbing failed"
379	        );
380	    } else {
381	        // current_dir() unavailable (rare) — still assert the harness recorded a
382	        // non-empty, real cwd (the plumbing produced something, not <no-cwd>).
383	        assert!(
384	            !resume_cwd.is_empty() && resume_cwd != "<no-cwd>",
385	            "the resumed harness must record a real cwd, got {resume_cwd:?}"
386	        );
387	    }
388	}
389

//! v0.13.0 P2 (REQ-SESSION-RESUME-TEMPLATE) — the real-fixture gate for the
//! resume-blank ship-blocker. Root cause: spt-core had `[session.self]` (fresh)
//! but no `[session.resume]` native-resume sibling, so `--resume` re-ran the
//! FRESH command → the harness started a blank transcript.
//!
//! This drives the SAME real spt-hosted bringup as `dummy_harness_e2e.rs` —
//! `spt endpoint run --start` → broker PTY → the dummy harness — against a REAL
//! detached `spt daemon run`, with a dummyharness adapter whose `[session.resume]`
//! command DIFFERS from `[session.self]`. The dummy writes a MARKER file recording
//! (a) which template launched it (`self` vs `resume`) and (b) the `current_dir()`
//! it landed in. We assert:
//!   - a FRESH `endpoint run --start` (no `--resume`) writes the SELF marker, and
//!     the RESUME marker is ABSENT;
//!   - a `--resume <session>` `endpoint run --start` writes the RESUME marker
//!     (proving template SELECTION), and the recorded cwd matches the cwd the
//!     bringup landed in (proving the cwd plumbing).
//!
//! Both bringups use `--start` (detached, return-now) — no attach pump — so the
//! marker file is the deterministic proof, with no PTY-output capture race. The
//! marker is written into the isolated SPT_HOME (always writable regardless of
//! the harness cwd); its CONTENTS carry the harness `current_dir()` so the cwd
//! assertion is independent of where the marker file itself lives.
//!
//! Bounded; all spawned pids reaped SCOPED (never machine-wide — shared runner).
//! [int->REQ-SESSION-RESUME-TEMPLATE]

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

use spt_store::perch::{self, ParentHint};

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete within its deadline")
        .expect("run spt")
}

fn ready_pid(path: &Path) -> Option<u32> {
    let s = std::fs::read_to_string(path).ok()?;
    serde_json::from_str::<serde_json::Value>(&s)
        .ok()?
        .get("pid")?
        .as_u64()
        .map(|p| p as u32)
}

fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Some(p) = ready_pid(path) {
            return Some(p);
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    None
}

fn sibling_bin(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_spt"))
        .with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

/// Poll for a marker file to appear (the dummy writes it before its heartbeat
/// loop), bounded. Returns its parsed `(template, cwd)` lines, or None on timeout.
fn wait_for_marker(path: &Path, budget: Duration) -> Option<(String, String)> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Ok(s) = std::fs::read_to_string(path) {
            let mut template = None;
            let mut cwd = None;
            for line in s.lines() {
                if let Some(v) = line.strip_prefix("template=") {
                    template = Some(v.to_string());
                }
                if let Some(v) = line.strip_prefix("cwd=") {
                    cwd = Some(v.to_string());
                }
            }
            if let (Some(t), Some(c)) = (template, cwd) {
                return Some((t, c));
            }
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    None
}

#[test]
fn endpoint_run_resume_selects_resume_template_in_recorded_cwd() {
    // ── (1) Isolated SPT_HOME. ──
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_session = sibling_bin("mock-session");
    assert!(
        mock_session.exists(),
        "the dummy-harness program must be built: {} \
         (pre-build: cargo build -p mock-adapter --bin mock-session)",
        mock_session.display()
    );

    // Distinct marker paths under the isolated home — always writable regardless
    // of the harness cwd. The marker CONTENTS carry the harness current_dir(), so
    // the cwd assertion is independent of where the marker file lives.
    let markers = home.path().join("markers");
    std::fs::create_dir_all(&markers).unwrap();
    let self_marker = markers.join("self.marker");
    let resume_marker = markers.join("resume.marker");
    let self_marker_for_cmd = self_marker.to_string_lossy().replace('\\', "/");
    let resume_marker_for_cmd = resume_marker.to_string_lossy().replace('\\', "/");

    // ── (2) Register the dummyharness adapter with a [session.self] that writes a
    //    SELF marker and a DISTINCT [session.resume] that writes a RESUME marker
    //    (plus the `--resume-marker` token the dummy stamps into the marker). Both
    //    keep the long-lived dummy heartbeating + binding its perch, so the real
    //    broker-PTY bringup path runs end-to-end. ──
    let spt_for_cmd = spt_bin.to_string_lossy().replace('\\', "/");
    let mock_for_cmd = mock_session.to_string_lossy().replace('\\', "/");
    let self_cmd = format!(
        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
         --spt {spt_for_cmd} --mock-adapter dummyresume --type live_agent --heartbeat-ms 200 \
         --marker-file {self_marker_for_cmd}"
    );
    let resume_cmd = format!(
        "{mock_for_cmd} --mode dummy --id {{id}} --session-id {{session_id}} \
         --spt {spt_for_cmd} --mock-adapter dummyresume --type live_agent --heartbeat-ms 200 \
         --marker-file {resume_marker_for_cmd} --resume-marker"
    );

    let exe_suffix = spt_bin
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();
    let src = perch::spt_home().join("srcs").join("dummyresume");
    std::fs::create_dir_all(&src).unwrap();
    // A long-lived psyche fixture so the brain's reconcile marks status=online
    // (cmd_bind gates online to live_agent && psyche_init.is_some()).
    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"dummyresume\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n[session.self]\ncommand='{self_cmd}'\nkeys=[\"id\",\"session_id\"]\n\n\
         [session.resume]\ncommand='{resume_cmd}'\nkeys=[\"id\",\"session_id\"]\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='psychebin ready {{id}}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    // ── (3) The REAL daemon broker process. ──
    let brain_log = home.path().join("brain.stderr.log");
    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .stdout(Stdio::null())
        .stderr(Stdio::from(brain_log_file))
        .spawn()
        .expect("spawn spt daemon run (broker process)");
    let brain_pid =
        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
            Some(p) => p,
            None => {
                let _ = broker.kill();
                let _ = broker.wait();
                panic!(
                    "PRECONDITION: brain never came up.\n{}",
                    std::fs::read_to_string(&brain_log).unwrap_or_default()
                );
            }
        };

    // ── (4) FRESH bringup: `endpoint run --start` with NO --resume → the harness
    //    runs [session.self] → SELF marker present, RESUME marker ABSENT. ──
    let id = "dummyresume1";
    let fresh_run = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["endpoint", "run", "--adapter", "dummyresume", "--id", id, "--start"])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(45))
    };
    let fresh_stderr = String::from_utf8_lossy(&fresh_run.stderr).to_string();
    let harness_pid_1: Option<u32> = fresh_stderr
        .split("pid=")
        .nth(1)
        .and_then(|s| s.split_whitespace().next())
        .and_then(|s| s.trim().parse().ok());

    // The fresh harness writes its SELF marker shortly after spawn.
    let self_seen = wait_for_marker(&self_marker, Duration::from_secs(20));
    // The launch cwd the broker inherited (the test process cwd) — what the
    // harness current_dir() should equal (the W1 --start shortcut inherits it).
    let launch_cwd = std::env::current_dir()
        .ok()
        .map(|p| p.to_string_lossy().into_owned());

    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
    let status = || spt_store::info::read_info(&self_perch).and_then(|i| i.status);
    let mut online_1 = false;
    let deadline = Instant::now() + Duration::from_secs(20);
    while Instant::now() < deadline {
        if status().as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
            online_1 = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(150));
    }

    // Record whether the RESUME marker exists BEFORE the resume run (must not).
    let resume_marker_before = resume_marker.exists();

    // ── (5) Drive the endpoint offline (kill the fresh harness), wait OFFLINE, so
    //    the resume re-bringup is a clean second spawn (not racing the first). ──
    if let Some(p) = harness_pid_1 {
        kill_pid(p);
    }
    let mut went_offline = false;
    let off_deadline = Instant::now() + Duration::from_secs(15);
    while Instant::now() < off_deadline {
        if status().as_deref() == Some(spt_store::liveness::STATUS_OFFLINE) {
            went_offline = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(150));
    }

    // ── (6) RESUME bringup: `endpoint run --resume <session> --start`. is_resume
    //    selects [session.resume] → RESUME marker present, recording current_dir().
    //    The session id is arbitrary (the dummy just echoes it) — what matters is
    //    that --resume flips template selection and the cwd plumbing lands. ──
    let resumed_session = "resumed-session-xyz";
    let resume_run = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args([
                "endpoint", "run", "--adapter", "dummyresume", "--id", id, "--resume",
                resumed_session, "--start",
            ])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(45))
    };
    let resume_stderr = String::from_utf8_lossy(&resume_run.stderr).to_string();
    let harness_pid_2: Option<u32> = resume_stderr
        .split("pid=")
        .nth(1)
        .and_then(|s| s.split_whitespace().next())
        .and_then(|s| s.trim().parse().ok());

    let resume_seen = wait_for_marker(&resume_marker, Duration::from_secs(20));

    // ── (7) Reap the ENTIRE tree SCOPED before asserting (never leave a stray
    //    spt.exe — a survivor locks target\debug\spt.exe). ──
    if let Some(p) = harness_pid_1 {
        kill_pid(p);
    }
    if let Some(p) = harness_pid_2 {
        kill_pid(p);
    }
    let psyche_perch = perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
    if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
        kill_pid(p);
    }
    let _ = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["daemon", "stop"])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(20))
    };
    kill_pid(brain_pid);
    let _ = broker.kill();
    let _ = broker.wait();
    let reap_deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < reap_deadline {
        let brain_gone = !spt_store::proc::is_process_alive(brain_pid);
        let h1_gone = harness_pid_1
            .map(|p| !spt_store::proc::is_process_alive(p))
            .unwrap_or(true);
        let h2_gone = harness_pid_2
            .map(|p| !spt_store::proc::is_process_alive(p))
            .unwrap_or(true);
        if brain_gone && h1_gone && h2_gone {
            break;
        }
        std::thread::sleep(Duration::from_millis(100));
    }

    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
    eprintln!(
        "=== DIAGNOSTIC (RESUME-TEMPLATE): fresh_ok={} online_1={online_1} \
         went_offline={went_offline} resume_ok={} resume_before={resume_marker_before} \
         self_seen={self_seen:?} resume_seen={resume_seen:?} launch_cwd={launch_cwd:?} ===\n\
         === fresh stderr ===\n{fresh_stderr}\n=== resume stderr ===\n{resume_stderr}\n\
         === brain stderr ===\n{brain_stderr}",
        fresh_run.status.success(),
        resume_run.status.success(),
    );

    // ── (8) THE GATES. ──
    assert!(
        fresh_run.status.success(),
        "fresh endpoint run --start must succeed: {fresh_stderr}"
    );
    assert!(
        online_1,
        "PRECONDITION: the fresh bringup must bind ONLINE via the real broker PTY.\n\
         === brain stderr ===\n{brain_stderr}"
    );

    // FRESH → SELF template selected; RESUME marker absent before the resume run.
    let (self_template, _self_cwd) = self_seen.expect(
        "the FRESH bringup (no --resume) must run [session.self] and write the SELF marker — \
         template selection failed",
    );
    assert_eq!(
        self_template, "self",
        "a fresh bringup must run the [session.self] template"
    );
    assert!(
        !resume_marker_before,
        "the RESUME marker must be ABSENT after a fresh (non-resume) bringup — the fresh \
         path must NOT run [session.resume]"
    );

    assert!(
        resume_run.status.success(),
        "resume endpoint run --start must succeed: {resume_stderr}"
    );

    // RESUME → the [session.resume] template was selected (the ship-blocker fix:
    // --resume no longer re-runs the FRESH command).
    let (resume_template, resume_cwd) = resume_seen.expect(
        "the --resume bringup must run [session.resume] and write the RESUME marker — \
         the resume-template SELECTION (REQ-SESSION-RESUME-TEMPLATE) failed; pre-fix --resume \
         re-ran [session.self] → blank transcript",
    );
    assert_eq!(
        resume_template, "resume",
        "a --resume bringup must run the [session.resume] template, NOT [session.self]"
    );

    // The cwd plumbing: the resumed harness landed in the launch cwd (the W1
    // --start shortcut inherits the broker's cwd, which inherits this test
    // process's cwd). Compare canonicalized to absorb path-form differences
    // (8.3 short names, symlinked temp dirs, trailing separators).
    if let Some(expected) = launch_cwd {
        let got = std::path::Path::new(&resume_cwd);
        let want = std::path::Path::new(&expected);
        let got_c = got.canonicalize().unwrap_or_else(|_| got.to_path_buf());
        let want_c = want.canonicalize().unwrap_or_else(|_| want.to_path_buf());
        assert_eq!(
            got_c, want_c,
            "the resumed harness must land in the recorded/launch cwd \
             (got {resume_cwd:?}, want {expected:?}) — the cwd plumbing failed"
        );
    } else {
        // current_dir() unavailable (rare) — still assert the harness recorded a
        // non-empty, real cwd (the plumbing produced something, not <no-cwd>).
        assert!(
            !resume_cwd.is_empty() && resume_cwd != "<no-cwd>",
            "the resumed harness must record a real cwd, got {resume_cwd:?}"
        );
    }
}


Int test is solid — real daemon+broker+PTY, marker-file proof, `--start` deterministic, scoped reaping, tests both template selection AND cwd. Checking mock extension + gate output:


40	        Some("dummy") => {
41	            // The DUMMY-HARNESS fixture (v0.12.1 real-harness gating): a long-lived
42	            // stand-in for a real harness session. Binds its perch (the harness
43	            // contract) exactly like the default stand-in, then prints a stdout
44	            // heartbeat on an interval and STAYS ALIVE until killed — so the REAL
45	            // `spt endpoint run` → `launch_harness_brokered_in` → broker PTY → `rc`
46	            // attach path can be exercised end-to-end (the default mode exits
47	            // immediately, which is why the v0.12.0 mock tests never caught the
48	            // daemon+broker+PTY lifecycle defects). Isolates "broker wedged /
49	            // endpoint died" from "a real harness failed to launch."
50	            use std::io::Write;
51	            let id = arg(&args, "--id").unwrap_or_else(|| {
52	                eprintln!("mock-session --mode dummy: --id required");
53	                std::process::exit(2);
54	            });
55	            let session_id =
56	                arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
57	            let spt = arg(&args, "--spt")
58	                .or_else(|| std::env::var("SPT_BIN").ok())
59	                .unwrap_or_else(|| "spt".to_string());
60	            let adapter = arg(&args, "--mock-adapter").unwrap_or_else(|| "mock".to_string());
61	            let endpoint_type = arg(&args, "--type");
62	            let heartbeat_ms: u64 = arg(&args, "--heartbeat-ms")
63	                .and_then(|s| s.parse().ok())
64	                .unwrap_or(500);
65
66	            // [int->REQ-SESSION-RESUME-TEMPLATE] Optional resume-template proof: when
67	            // `--marker-file <path>` is set, write a marker whose CONTENTS record
68	            // (a) which session template launched this dummy — `resume` if the
69	            // command carried `--resume-marker`, else `self` — and (b) the
70	            // `current_dir()` the harness landed in. The int test declares a
71	            // `[session.self]` with a SELF marker path and a `[session.resume]` with
72	            // a DIFFERENT RESUME marker path (plus the `--resume-marker` token), so a
73	            // `--resume` bringup proves both the template SELECTION and the cwd
74	            // plumbing. Tiny + cross-platform; absent ⇒ unchanged dummy behavior.
75	            if let Some(marker) = arg(&args, "--marker-file") {
76	                let which = if args.iter().any(|a| a == "--resume-marker") {
77	                    "resume"
78	                } else {
79	                    "self"
80	                };
81	                let cwd = std::env::current_dir()
82	                    .map(|p| p.display().to_string())
83	                    .unwrap_or_else(|_| "<no-cwd>".to_string());
84	                // `template=<self|resume>\ncwd=<dir>\n` — a parseable two-line proof.
85	                let _ = std::fs::write(&marker, format!("template={which}\ncwd={cwd}\n"));
86	            }
87
88	            // Bind the perch live (spt-hosted direct bind, mirroring the default
89	            // stand-in below). A failure is fatal — the fixture must come up.
90	            let mut bind_args = vec![
91	                "api".to_string(),
92	                "--adapter".to_string(),
93	                adapter,
94	                "bind".to_string(),
95	                id.clone(),
96	                "--set-session-id".to_string(),
97	                session_id,
98	            ];
99	            if let Some(ty) = &endpoint_type {
100	                bind_args.push("--type".to_string());
101	                bind_args.push(ty.clone());

        Some("dummy") => {
            // The DUMMY-HARNESS fixture (v0.12.1 real-harness gating): a long-lived
            // stand-in for a real harness session. Binds its perch (the harness
            // contract) exactly like the default stand-in, then prints a stdout
            // heartbeat on an interval and STAYS ALIVE until killed — so the REAL
            // `spt endpoint run` → `launch_harness_brokered_in` → broker PTY → `rc`
            // attach path can be exercised end-to-end (the default mode exits
            // immediately, which is why the v0.12.0 mock tests never caught the
            // daemon+broker+PTY lifecycle defects). Isolates "broker wedged /
            // endpoint died" from "a real harness failed to launch."
            use std::io::Write;
            let id = arg(&args, "--id").unwrap_or_else(|| {
                eprintln!("mock-session --mode dummy: --id required");
                std::process::exit(2);
            });
            let session_id =
                arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
            let spt = arg(&args, "--spt")
                .or_else(|| std::env::var("SPT_BIN").ok())
                .unwrap_or_else(|| "spt".to_string());
            let adapter = arg(&args, "--mock-adapter").unwrap_or_else(|| "mock".to_string());
            let endpoint_type = arg(&args, "--type");
            let heartbeat_ms: u64 = arg(&args, "--heartbeat-ms")
                .and_then(|s| s.parse().ok())
                .unwrap_or(500);

            // [int->REQ-SESSION-RESUME-TEMPLATE] Optional resume-template proof: when
            // `--marker-file <path>` is set, write a marker whose CONTENTS record
            // (a) which session template launched this dummy — `resume` if the
            // command carried `--resume-marker`, else `self` — and (b) the
            // `current_dir()` the harness landed in. The int test declares a
            // `[session.self]` with a SELF marker path and a `[session.resume]` with
            // a DIFFERENT RESUME marker path (plus the `--resume-marker` token), so a
            // `--resume` bringup proves both the template SELECTION and the cwd
            // plumbing. Tiny + cross-platform; absent ⇒ unchanged dummy behavior.
            if let Some(marker) = arg(&args, "--marker-file") {
                let which = if args.iter().any(|a| a == "--resume-marker") {
                    "resume"
                } else {
                    "self"
                };
                let cwd = std::env::current_dir()
                    .map(|p| p.display().to_string())
                    .unwrap_or_else(|_| "<no-cwd>".to_string());
                // `template=<self|resume>\ncwd=<dir>\n` — a parseable two-line proof.
                let _ = std::fs::write(&marker, format!("template={which}\ncwd={cwd}\n"));
            }

            // Bind the perch live (spt-hosted direct bind, mirroring the default
            // stand-in below). A failure is fatal — the fixture must come up.
            let mut bind_args = vec![
                "api".to_string(),
                "--adapter".to_string(),
                adapter,
                "bind".to_string(),
                id.clone(),
                "--set-session-id".to_string(),
                session_id,
            ];
            if let Some(ty) = &endpoint_type {
                bind_args.push("--type".to_string());
                bind_args.push(ty.clone());


Activate REQ-SESSION-RESUME-TEMPLATE [doc,impl,unit,int]

Set required_stages in traceable-reqs.toml; registry-first before evidence.

Add [session.resume] manifest role

manifest.rs: resume: Option<SessionRole> on Session + roles() + is_empty(), mirror psyche_resume.

Add per-row cwd to session ledger

sessions.rs SessionEntry gets cwd: Option<String> additive serde-default; populate at boundary (reporting.rs) + bind (startup.rs) append sites from rec.cwd.

Resume-vs-self template selection in spawn path

harnesshost prepare_harness_spawn + launch_harness_brokered_in take is_resume; select session.resume when set+declared else self_ fallback. cmd_endpoint_run threads resume + cwd fallback chain (row->info.cwd->current_dir).

Picker: thread row cwd + title reformat

ResumeRow.cwd, Outcome::Run.cwd, resume_outcome threads cwd, data.rs maps e.cwd, run() dispatch passes cwd to cmd_endpoint_run; ResumeRow::title() local-time reformat (D).

Public docs: [session.resume] (MANIFEST + harness-contract)

docs/MANIFEST.md + harness-contract page teach [session.resume] so perri builds blind. doc->REQ tag. docs-drift regen.

Tests: unit + int (delegate spt-test-engineer)

unit: manifest parse/roundtrip resume, resume-vs-self selection, cwd fallback chain, ledger back-compat. int: dummy-harness resume cmd != self, runs in recorded cwd. Then clippy/traceable/docs-drift gates.


<task-notification>
<task-id>byty6skd3</task-id>
<tool-use-id>toolu_01Dg8WPce2zQtsWTu1s9Sd6J</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\97787e5e-64b2-4afd-b3fe-becaa2ea8030\tasks\byty6skd3.output</output-file>
<status>completed</status>
<summary>Background command "Independent gates: clippy + traceable + real bin" completed (exit code 0)</summary>
</task-notification>


Mock extension clean — additive, `--marker-file`-gated, absent = unchanged. Checking independent gate output:


1	=== CLIPPY (CI form) ===
2	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.68s
3	=== TRACEABLE ===
4	       No endpoint process terminates/suspends during self-update
5	  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
6	       Update gated on user confirmation by default; opt-in full-auto
7	  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
8	       spt-core ripple-updates registered adapters
9	  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
10	       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
11	  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
12	       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
13	  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
14	       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
15	  [OK] REQ-UPD-9  required: [doc, impl, unit]  stages: +doc +impl +unit -int
16	       `gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = "gh_release", repo = "user/repo"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8).
17	  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
18	       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
19	=== BUILD REAL BIN ===
20	   Compiling iroh v0.98.2
21	   Compiling spt-net v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
22	   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
23	   Compiling spt v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
24	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.88s
25

=== CLIPPY (CI form) ===
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.68s
=== TRACEABLE ===
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
  [OK] REQ-UPD-9  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = "gh_release", repo = "user/repo"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8).
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
=== BUILD REAL BIN ===
   Compiling iroh v0.98.2
   Compiling spt-net v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.88s


Prompt:
<task-notification>
<task-id>byty6skd3</task-id>
<tool-use-id>toolu_01Dg8WPce2zQtsWTu1s9Sd6J</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\97787e5e-64b2-4afd-b3fe-becaa2ea8030\tasks\byty6skd3.output</output-file>
<status>completed</status>
<summary>Background command "Independent gates: clippy + traceable + real bin" completed (exit code 0)</summary>
</task-notification>


All gates green independently: clippy 0 warnings, traceable exit 0, real bin built (exit 0). Confirming the specific REQ line for doyle's surface:


traceable-reqs check 2>&1 | grep -A1 "REQ-SESSION-RESUME-TEMPLATE"

Confirm REQ-SESSION-RESUME-TEMPLATE coverage line


  [OK] REQ-SESSION-RESUME-TEMPLATE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Resuming an endpoint session that HAS conversation history brings up a BLANK session. ROOT (doyle, code-grounded + CONTEXT — case-3 spt-core MISSING feature, NOT a perri docs-miss): CONTEXT L127-129 already defines the resume-session seam ('continue-existing: resume an existing harness session under the adapter — its NATIVE resume'), and the manifest already has the resume-variant pattern (Session has BOTH psyche_init AND psyche_resume, manifest.rs:217-219) — but the agent's own session has ONLY self_ (`[session.self]`, no resume sibling). cmd_endpoint_run (cli.rs:1304) re-passes the session_id through `[session.self]` on resume (resume.unwrap_or_else(mint_session_id)), so the adapter's FRESH command (e.g. `claude --session-id ..`) runs again instead of the harness NATIVE resume (`claude -r ..`) -> CC starts a fresh transcript -> blank. spt-core forwards session_id + cwd faithfully; it just has no way to express the native-resume invocation. SECOND GAP: CC resolves a transcript by session_id + cwd, but the session ledger records only {ts, session_id, trigger} (no cwd), so picker Resume-from-history (cross-project rows) can't restore the right cwd. FIX (doyle design, V0.13.0-P2-SESSION-RESUME-DESIGN.md, mirrors psyche_init->psyche_resume exactly): (A) add a `[session.resume]` role (resume: Option<SessionRole> on Session + roles()/is_empty()); cmd_endpoint_run selects it when --resume is set AND it's declared (fill {id}/{session_id}=resumed id/{session_name} + the resume cwd), else FALL BACK to `[session.self]` (full back-compat). (B) record cwd PER ledger row (operator ruling): {ts, session_id, trigger, cwd} additive serde-default; resume cwd = resumed row cwd -> else perch info.cwd -> else current_dir (back-compat for old rows + single-project endpoints); picker threads the selected row's cwd through Outcome::Run -> cmd_endpoint_run. (C) public docs (MANIFEST + harness-contract) teach `[session.resume]` so perri builds the adapter side BLIND. Adapter follow-on (perri, AFTER spt-core ships+docs): declare `[session.resume] command = claude -r {session_id} --remote-control {id} --dangerously-skip-permissions` from the resume cwd. Completes REQ-READY-AGENT-RESUME / REQ-RUN-PICKER resume-from-history. (v0.13.0)


**P2 GREEN.** `REQ-SESSION-RESUME-TEMPLATE: +doc +impl +unit +int`. All gates pass independently (clippy 0, traceable exit 0, real bin built). Committing on the build branch, then surfacing to doyle.


cd "C:/Users/decid/Documents/projects/spt-core" && git add -A && git status --short | head -40

Stage all P2 changes


warning: in the working copy of 'crates/spt-runtime/manifest.schema.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/resume_template_e2e.rs', LF will be replaced by CRLF the next time Git touches it
M  adapters/mock/src/main.rs
M  crates/spt-daemon/src/digest.rs
M  crates/spt-daemon/src/harnesshost.rs
M  crates/spt-daemon/tests/attach.rs
M  crates/spt-daemon/tests/broker.rs
M  crates/spt-daemon/tests/two_origin_spanning.rs
M  crates/spt-msg/src/ready.rs
M  crates/spt-runtime/manifest.schema.json
M  crates/spt-runtime/src/manifest.rs
M  crates/spt-store/src/sessions.rs
M  crates/spt/src/api/reporting.rs
M  crates/spt/src/api/startup.rs
M  crates/spt/src/cli.rs
M  crates/spt/src/picker/data.rs
M  crates/spt/src/picker/mod.rs
M  crates/spt/src/picker/model.rs
M  crates/spt/src/picker/view.rs
A  crates/spt/tests/resume_template_e2e.rs
M  docs-site/src/harness-contract/manifest.md
M  docs/MANIFEST.md
M  traceable-reqs.toml


cd "C:/Users/decid/Documents/projects/spt-core" && git commit -q -F - <<'EOF'
feat(session): [session.resume] native-resume role + per-row ledger cwd (REQ-SESSION-RESUME-TEMPLATE)

v0.13.0 P2 — resume-blank ship-blocker. Resuming an endpoint with history
brought up a BLANK session: spt-core had [session.self] (fresh) but no
native-resume sibling, so --resume re-ran the fresh command (claude
--session-id ..) instead of the harness native resume (claude -r ..) → blank
transcript. Second gap: the session ledger had no per-row cwd, so cross-project
picker Resume couldn't restore the dir a harness needs to resolve a transcript
(session_id + cwd).

Fix (mirrors psyche_init→psyche_resume):
- manifest: Session.resume role (+ roles()/is_empty()); schema blessed.
- harnesshost: prepare_harness_spawn(is_resume) selects [session.resume] when
  declared, else falls back to [session.self] (full back-compat); threaded
  through launch_harness_brokered_in.
- cli: cmd_endpoint_run gains cwd param + is_resume; cwd fallback chain
  (picker row cwd → resume info.cwd → current_dir).
- ledger: SessionEntry.cwd additive serde-default; written at boundary/bind/
  ready appends from info.cwd; old rows deserialize cwd:None.
- picker: ResumeRow.cwd + Outcome::Run.cwd threaded; resume_outcome carries the
  row cwd; ResumeRow::title() reformatted to local-time (design D, %z offset —
  chrono %Z unreliable cross-platform; HITL-confirm the form).
- docs: MANIFEST.md + harness-contract teach [session.resume] (perri-buildable).

Tests: manifest parse/roundtrip/back-compat; resume-vs-self selection + self
fallback; ledger cwd roundtrip + pre-migration row; real daemon+broker+PTY int
(dummy-harness whose resume cmd differs from self — --resume runs resume in the
recorded cwd via marker assert, fresh runs self). Mock gained --marker-file
(additive). clippy=0, traceable +doc+impl+unit+int, docs-drift OK.

Co-authored by: todlando
EOF
echo "=== committed ===" && git log --oneline -1

Commit P2 work


=== committed ===
153c9c9 feat(session): [session.resume] native-resume role + per-row ledger cwd (REQ-SESSION-RESUME-TEMPLATE)


Committed @`153c9c9`. Surfacing to doyle for gate.


Updated task #7 status


$OWL send doyle <<'EOF'
P2 GREEN — surfacing for your gate. REQ-SESSION-RESUME-TEMPLATE @153c9c9 (branch v0.13.0-delivery-control).

stages: +doc +impl +unit +int (traceable EXIT=0)
gates (independently re-run): clippy --workspace --all-targets -D warnings = 0 · docs-drift xtask check OK (schema blessed) · real bin built OK

BUILD (mirrors psyche_init->psyche_resume):
- (A) Session.resume role + roles()/is_empty(); prepare_harness_spawn(is_resume) selects [session.resume] when declared else FALLS BACK to [session.self] (back-compat); threaded thru launch_harness_brokered_in. cmd_endpoint_run gains is_resume.
- (B) SessionEntry.cwd additive serde-default; written at boundary/bind/ready appends from info.cwd; old rows -> cwd:None. cmd_endpoint_run cwd fallback chain: picker row cwd -> resume info.cwd -> current_dir. Picker threads row cwd thru Outcome::Run.
- (C) public docs: MANIFEST.md + harness-contract/manifest.md teach [session.resume] (perri-buildable, your perri-follow-on example included verbatim).
- (D) ResumeRow::title() local-time reformat.

int = real daemon+broker+PTY dummy-harness whose resume cmd != self: --resume runs RESUME template in recorded cwd (marker assert), fresh runs SELF. Mock gained additive --marker-file (existing dummy_harness_e2e unaffected).

TWO HITL FLAGS for operator confirm:
1. TZ form (design D caveat): chrono %Z emits long names on Windows ("Pacific Standard Time"), so I used the numeric UTC offset %z -> e.g. `myproject - 03:45PM -0800 06-19-2026 (…7fb10)`. Design named this the acceptable fallback. Confirm offset is OK vs wanting a short abbrev.
2. test-isolation caveat (not a defect): spt-daemon e2e suite must run --test-threads=1 (a shared-state daemon e2e races under parallel runner; single-threaded green). Pre-existing convention, logged.

Yours to gate. Next: P0 (REQ-HAZARD-PTY-INPUT-WRITER-WEDGE, the active ship-blocker) — starting build unless you want changes here first.
EOF

Surface P2 green to doyle for gate


➜ SENT:doyle


P2 done, committed @`153c9c9`, surfaced to doyle for gate. All 7 subtasks complete.

**P2 shipped (pending doyle gate):**
- All build items A–D + public docs
- Tests: unit (manifest/harnesshost/ledger) + real daemon+broker+PTY int — mutation-proven by subagent
- Independent gates: clippy 0 · traceable `+doc+impl+unit+int` · docs-drift OK · real bin built
- 2 HITL flags raised to doyle: TZ form (`%z` offset vs abbrev) + spt-daemon e2e `--test-threads=1` caveat

**Next:** P0 (`REQ-HAZARD-PTY-INPUT-WRITER-WEDGE`) — the active ship-blocker, P1 depends on it. Large build (per-session PTY single-writer thread + bounded FIFO + broker dispatch rework). Holding for doyle's P2 gate verdict before starting — if changes wanted on P2 I fix first; if clean, P0 is a fresh large body worth a clean context. Idle + reachable for doyle's reply.


<task-notification>
<task-id>b4cwwiglf</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/3" id="93f11278" type="msg" from="doyle">P2 GATE-PASS @153c9c9 (REQ-SESSION-RESUME-TEMPLATE). Independently re-verified: clippy=0, docs-drift OK, all units (manifest/harnesshost selection+fallback/ledger cwd roundtrip+pre-migration+none-omit/picker), int resume_template_e2e NON-VACUOUS both arms (fresh-&gt;self, --resume-&gt;resume in recorded cwd), traceable </EVENT-PART>
<EVENT-PART seq="2/3" id="93f11278">[doc,impl,unit,int] + REQ-RUN-PICKER covered, docs perri-buildable. Clean back-compat. Excellent work.

PROCEED TO P0 (REQ-HAZARD-PTY-INPUT-WRITER-WEDGE) — the active ship-blocker, no changes needed here.

Two notes (NON-blocking):
1. TZ form: your %z offset (-0800) is the design-D acceptable fallback — fine to LEAVE AS-IS p</EVENT-PART>
<EVENT-PART seq="3/3" id="93f11278">ending operator HITL confirm (operator may want a short abbrev; I'm asking them now). Don't rework unless I relay otherwise.
2. test-threads=1 for spt-daemon e2e — logged, pre-existing convention, fine.

Surface P0 green for gate. Reachable.</EVENT-PART></event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


doyle GATE-PASS P2 @`153c9c9`. Proceed to P0, TZ leave as-is. P2 fully closed.

P0 is the active ship-blocker — large build. Acking doyle + grounding on P0 design to JIT-plan before a clean-context handoff.


$OWL send doyle <<'EOF'
ACK P2 gate-pass @153c9c9 — closed. TZ %z left as-is pending your operator HITL relay.

Starting P0 (REQ-HAZARD-PTY-INPUT-WRITER-WEDGE) now — per-session input-writer thread (sole write_input caller) + bounded FIFO + try-enqueue at dispatch_input/serve_attach send_effect/inject-floor + DROP+INPUT_BACKPRESSURE on saturation + apply_once PtyWrite=enqueue. REPRO-FIRST int (W1b's int escaped it). Context high after P2 — JIT-planning P0 + self-clearing to a fresh context, then building. Will surface P0 green for your gate. Reachable throughout.
EOF

Ack P2 gate-pass, announce P0 start


1	# v0.13.0 P0 — PTY input single-writer thread (paste-wedge fix; doyle 2026-06-19)
2
3	**Operator HITL:** pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type, and can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). BLOCKS v0.13.0 ship. Builder: todlando. Gate: doyle.
4
5	## Root (doyle /diagnose, code-grounded)
6	The operator keystroke path: `rc` → net-stream `Input` → `serve_attach` (attach.rs:197 `brain.send_effect(op_id)`) → KIND_INPUT → broker dispatch loop (broker.rs:1091) → `dispatch_input` (broker.rs:1459) → `session.write_input(&bytes)` **synchronously on the broker request-handling thread**.
7
8	W1b (`REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE`, shipped) released the effect-journal lock ACROSS the effect (fix 1) and made `PtyWrite` ephemeral / no-fsync (fix 3). But it **explicitly DEFERRED fix (2)** — "bound/fail-fast the PtyWrite itself; write_input must never block indefinitely." `write_input` still runs synchronously on the dispatch thread. A single keystroke never fills the ConPTY input buffer; a **paste burst does** → `write_input` blocks → the thread cannot service the next frame (a re-attach's subscribe, a become_controller restore-write, an inject-floor flush) → wedge. This is THE paste-trigger of the W1b-deferred (2).
9
10	**Not a bug-2 regression** — the byte path funnels to the same `write_input`; the paste just reliably fills the buffer. The exact cross-attach starvation vector (a shared per-session writer mutex held by the stuck write vs the dispatch-thread block) is **pinned by the repro** (below); the fix cures it either way.
11
12	## CONTEXT.md grounding (design source of truth)
13	- L33: the **broker** holds only the un-transferable must-not-die resources (PTY master fds, child processes, sockets) and is **minimal, dumb, almost-never-updates**. A per-session writer thread is a broker-owned PTY-management detail — fits the charter; adds no brain/logic.
14	- L435: `spt_term::SessionSurface` = the `write_input` + `resize` seam (`send_keys`/`send_line` first-class). The single-writer change lives behind this seam.
15	- L437-438: input injection is two granularities (send-keys raw bytes / send-line cooked) — both still first-class, just enqueued.
16	- Single-writer is the established spt-core pattern (broker-owned `driven_by`, daemon-owned drop files — KNOWN-HAZARDS 6.4). This extends it to the PTY **input** side.
17
18	## Decision: one input-writer thread per session = the SOLE PTY writer
19	- Each `PtySession` (broker.rs, when the session is created) spawns ONE dedicated **input-writer thread** that owns the PTY master write handle and is the **only** code that calls the blocking `write_input`.
20	- It is fed by a **bounded FIFO channel** (`mpsc`/`sync_channel` with a fixed capacity — sized for a generous paste, e.g. a few hundred KB worth of records).
21	- EVERY PTY-write caller — `dispatch_input`, `serve_attach`→`send_effect`, the W2 inject-floor flush (`flush_inject_floor`) — **ENQUEUES a record and returns immediately**. No caller ever touches the blocking write or the PTY writer lock.
22	- The writer thread drains the channel to `write_input` at the harness's pace. A blocked/slow harness blocks ONLY its own writer thread — the broker dispatch thread, attach-opens, resizes, and every other session stay fully live.
23
24	## Backpressure (operator ruling: DROP + surface)
25	- Queue full (a genuinely wedged harness that never drains): **drop the excess input** and **stamp the session `INPUT_BACKPRESSURE`** (a visible health signal surfaced to the operator — "input dropped, harness not draining"). The daemon NEVER wedges. A truly-stuck harness is already dead; dropping input beats freezing the daemon (fail-fast half of fix 2; same spirit as `REQ-HAZARD-RC-ATTACH-FAILFAST`).
26	- A merely SLOW (not dead) harness self-heals: the writer thread drains as the harness catches up, input resumes — strictly better than today's hard wedge.
27	- Enqueue itself is non-blocking (try_send); a full channel returns the drop signal, never blocks the dispatch thread.
28
29	## Exactly-once (preserved)
30	- `apply_once(key, PtyWrite, effect)` where the `effect` is now the **enqueue** (fast, non-blocking): reserve key under lock → try-enqueue → mark Applied. `PtyWrite` is already ephemeral (no journal lines, no fsync), so marking applied at enqueue is sound — a keystroke lost to a broker crash is retyped; PTY state is never rebuilt from keystroke replay (effect.rs:189-199 invariant).
31	- The brain/operator ack (`applied_envelope`) now means "accepted + ordered + will land," not "fsynced to the PTY." The rc pump does not gate on landing (fire-and-forward, op_id is for dedup only), so the ack-timing shift is benign.
32	- Order preserved: single FIFO + single writer = strict keystroke order. Same-key dedup unchanged (applied-set).
33	- Durable kinds (NetSend/NetDial/Registry/Spool) do NOT route through `write_input` — untouched, keep their fsync'd PENDING/DONE.
34	- W2 inject-floor (`REQ` Layer C) buffering moves to its natural home: the writer thread is the lone writer, so the "buffer operator keystrokes during an injected sequence, flush after commit" choreography is enforced THERE (no second writer to race).
35
36	## Repro-first (W1b's int ESCAPED this — non-negotiable)
37	W1b's int relied on ConPTY "absorbing" the write, so it never exercised an INDEFINITE block. The new int MUST:
38	1. Make `write_input` **actually block forever** — a real PTY consumer (harness stand-in) that stops draining its stdin, so the ConPTY/forkpty input buffer genuinely fills and the write parks.
39	2. Prove a **concurrent `spt rc` attach still OPENS and RECEIVES output** while that write is parked (RED pre-fix = `brain IPC read deadline` / no subscribe; GREEN post-fix).
40	3. Prove the **operator's own next keystroke is still accepted** (enqueued) post-paste.
41	4. Prove **DROP + `INPUT_BACKPRESSURE`** fires when the queue saturates (and clears/heals when the consumer resumes).
42	Real broker + real PTY + real rc pump, NO mocks (W1b precedent). cfg(unix) forkpty park covered too (the W1b "gravity-linux follow-up" leg folds in here).
43
44	## REQ + gates
45	- **Mint `REQ-HAZARD-PTY-INPUT-WRITER-WEDGE`** (registry-first), stages **impl, unit, int**. References + COMPLETES the W1b-deferred fix (2). Note in `REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE` that (2) is delivered here.
46	- KNOWN-HAZARDS entry (7.17) + CONTEXT note (PTY input is single-writer via a per-session writer thread; callers enqueue, never block; drop+signal on a wedged harness).
47	- unit: the writer-thread drain order + the bounded-queue drop/heal decision (injected channel, no wall-clock) + exactly-once enqueue (PtyWrite reserve→enqueue→Applied; dedup).
48	- int: the repro above.
49	- clippy --workspace -D warnings = 0; traceable EXIT=0; docs-drift xtask check.
50
51	## doyle gate criteria
52	Repro genuinely blocks write_input (non-vacuous: RED pre-fix) · concurrent attach opens + gets output under a parked write · operator keystroke still accepted · DROP+`INPUT_BACKPRESSURE` on saturation, heal on resume · exactly-once + order preserved · inject-floor choreography intact · Unix forkpty leg covered · clippy/traceable/docs-drift. Then operator HITL (paste a large block in a real CC rc session: lands, no wedge, can still attach).
53
54	**BLOCKS the v0.13.0 ship.**
55

# v0.13.0 P0 — PTY input single-writer thread (paste-wedge fix; doyle 2026-06-19)

**Operator HITL:** pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type, and can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). BLOCKS v0.13.0 ship. Builder: todlando. Gate: doyle.

## Root (doyle /diagnose, code-grounded)
The operator keystroke path: `rc` → net-stream `Input` → `serve_attach` (attach.rs:197 `brain.send_effect(op_id)`) → KIND_INPUT → broker dispatch loop (broker.rs:1091) → `dispatch_input` (broker.rs:1459) → `session.write_input(&bytes)` **synchronously on the broker request-handling thread**.

W1b (`REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE`, shipped) released the effect-journal lock ACROSS the effect (fix 1) and made `PtyWrite` ephemeral / no-fsync (fix 3). But it **explicitly DEFERRED fix (2)** — "bound/fail-fast the PtyWrite itself; write_input must never block indefinitely." `write_input` still runs synchronously on the dispatch thread. A single keystroke never fills the ConPTY input buffer; a **paste burst does** → `write_input` blocks → the thread cannot service the next frame (a re-attach's subscribe, a become_controller restore-write, an inject-floor flush) → wedge. This is THE paste-trigger of the W1b-deferred (2).

**Not a bug-2 regression** — the byte path funnels to the same `write_input`; the paste just reliably fills the buffer. The exact cross-attach starvation vector (a shared per-session writer mutex held by the stuck write vs the dispatch-thread block) is **pinned by the repro** (below); the fix cures it either way.

## CONTEXT.md grounding (design source of truth)
- L33: the **broker** holds only the un-transferable must-not-die resources (PTY master fds, child processes, sockets) and is **minimal, dumb, almost-never-updates**. A per-session writer thread is a broker-owned PTY-management detail — fits the charter; adds no brain/logic.
- L435: `spt_term::SessionSurface` = the `write_input` + `resize` seam (`send_keys`/`send_line` first-class). The single-writer change lives behind this seam.
- L437-438: input injection is two granularities (send-keys raw bytes / send-line cooked) — both still first-class, just enqueued.
- Single-writer is the established spt-core pattern (broker-owned `driven_by`, daemon-owned drop files — KNOWN-HAZARDS 6.4). This extends it to the PTY **input** side.

## Decision: one input-writer thread per session = the SOLE PTY writer
- Each `PtySession` (broker.rs, when the session is created) spawns ONE dedicated **input-writer thread** that owns the PTY master write handle and is the **only** code that calls the blocking `write_input`.
- It is fed by a **bounded FIFO channel** (`mpsc`/`sync_channel` with a fixed capacity — sized for a generous paste, e.g. a few hundred KB worth of records).
- EVERY PTY-write caller — `dispatch_input`, `serve_attach`→`send_effect`, the W2 inject-floor flush (`flush_inject_floor`) — **ENQUEUES a record and returns immediately**. No caller ever touches the blocking write or the PTY writer lock.
- The writer thread drains the channel to `write_input` at the harness's pace. A blocked/slow harness blocks ONLY its own writer thread — the broker dispatch thread, attach-opens, resizes, and every other session stay fully live.

## Backpressure (operator ruling: DROP + surface)
- Queue full (a genuinely wedged harness that never drains): **drop the excess input** and **stamp the session `INPUT_BACKPRESSURE`** (a visible health signal surfaced to the operator — "input dropped, harness not draining"). The daemon NEVER wedges. A truly-stuck harness is already dead; dropping input beats freezing the daemon (fail-fast half of fix 2; same spirit as `REQ-HAZARD-RC-ATTACH-FAILFAST`).
- A merely SLOW (not dead) harness self-heals: the writer thread drains as the harness catches up, input resumes — strictly better than today's hard wedge.
- Enqueue itself is non-blocking (try_send); a full channel returns the drop signal, never blocks the dispatch thread.

## Exactly-once (preserved)
- `apply_once(key, PtyWrite, effect)` where the `effect` is now the **enqueue** (fast, non-blocking): reserve key under lock → try-enqueue → mark Applied. `PtyWrite` is already ephemeral (no journal lines, no fsync), so marking applied at enqueue is sound — a keystroke lost to a broker crash is retyped; PTY state is never rebuilt from keystroke replay (effect.rs:189-199 invariant).
- The brain/operator ack (`applied_envelope`) now means "accepted + ordered + will land," not "fsynced to the PTY." The rc pump does not gate on landing (fire-and-forward, op_id is for dedup only), so the ack-timing shift is benign.
- Order preserved: single FIFO + single writer = strict keystroke order. Same-key dedup unchanged (applied-set).
- Durable kinds (NetSend/NetDial/Registry/Spool) do NOT route through `write_input` — untouched, keep their fsync'd PENDING/DONE.
- W2 inject-floor (`REQ` Layer C) buffering moves to its natural home: the writer thread is the lone writer, so the "buffer operator keystrokes during an injected sequence, flush after commit" choreography is enforced THERE (no second writer to race).

## Repro-first (W1b's int ESCAPED this — non-negotiable)
W1b's int relied on ConPTY "absorbing" the write, so it never exercised an INDEFINITE block. The new int MUST:
1. Make `write_input` **actually block forever** — a real PTY consumer (harness stand-in) that stops draining its stdin, so the ConPTY/forkpty input buffer genuinely fills and the write parks.
2. Prove a **concurrent `spt rc` attach still OPENS and RECEIVES output** while that write is parked (RED pre-fix = `brain IPC read deadline` / no subscribe; GREEN post-fix).
3. Prove the **operator's own next keystroke is still accepted** (enqueued) post-paste.
4. Prove **DROP + `INPUT_BACKPRESSURE`** fires when the queue saturates (and clears/heals when the consumer resumes).
Real broker + real PTY + real rc pump, NO mocks (W1b precedent). cfg(unix) forkpty park covered too (the W1b "gravity-linux follow-up" leg folds in here).

## REQ + gates
- **Mint `REQ-HAZARD-PTY-INPUT-WRITER-WEDGE`** (registry-first), stages **impl, unit, int**. References + COMPLETES the W1b-deferred fix (2). Note in `REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE` that (2) is delivered here.
- KNOWN-HAZARDS entry (7.17) + CONTEXT note (PTY input is single-writer via a per-session writer thread; callers enqueue, never block; drop+signal on a wedged harness).
- unit: the writer-thread drain order + the bounded-queue drop/heal decision (injected channel, no wall-clock) + exactly-once enqueue (PtyWrite reserve→enqueue→Applied; dedup).
- int: the repro above.
- clippy --workspace -D warnings = 0; traceable EXIT=0; docs-drift xtask check.

## doyle gate criteria
Repro genuinely blocks write_input (non-vacuous: RED pre-fix) · concurrent attach opens + gets output under a parked write · operator keystroke still accepted · DROP+`INPUT_BACKPRESSURE` on saturation, heal on resume · exactly-once + order preserved · inject-floor choreography intact · Unix forkpty leg covered · clippy/traceable/docs-drift. Then operator HITL (paste a large block in a real CC rc session: lands, no wedge, can still attach).

**BLOCKS the v0.13.0 ship.**


Full P0 design in hand. REQ is already SEEDED (saw it earlier). Before self-clearing I'll write a grounded JIT plan with real anchors. Gathering broker.rs structure:


Grep

struct PtySession|fn write_input|fn dispatch_input|fn send_effect|flush_inject_floor|inject_floor|fn apply_once|INPUT_BACKPRESSURE|PtyWrite|fn new.*Session|writer|sync_channel