1885	fn cmd_send(
1886	    target: Option<String>,
1887	    from: Option<String>,
1888	    reply_to: Option<String>,
1889	    deferred: bool,
1890	) -> i32 {
1891	    let is_reply = reply_to.is_some();
1892	    // --reply-to provides the target when no positional target is given.
1893	    let target = match target.or_else(|| reply_to.clone()) {
1894	        Some(t) => t,
1895	        None => {
1896	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
1897	            return 2;
1898	        }
1899	    };
1900	    let from = resolve_from(from);
1901	    let body = read_stdin();
1902	    let owlery = perch::owlery_dir();
1903
1904	    let outcome = if deferred {
1905	        deliver::send_deferred(&target, &from, &body)
1906	    } else {
1907	        deliver::send(&target, &from, &body, &owlery)
1908	    };
1909
1910	    match outcome {
1911	        SendOutcome::Sent => {
1912	            ok_line(format!(
1913	                "{}:{}",
1914	                if is_reply { "REPLIED" } else { "SENT" },
1915	                target
1916	            ));
1917	            0
1918	        }
1919	        SendOutcome::Queued => {
1920	            let label = if deferred {
1921	                "DEFERRED"
1922	            } else if is_reply {
1923	                "REPLIED(QUEUED)"
1924	            } else {
1925	                "QUEUED"
1926	            };
1927	            ok_line(format!("{}:{}", label, target));
1928	            0
1929	        }
1930	        SendOutcome::NoPerch => {
1931	            if deferred {
1932	                // The wire record carries no deferred class (v1) — a remote
1933	                // deferred send is a documented seam, not a silent reclass.
1934	                eprintln!(
1935	                    "NO_PERCH:{} is not listening (deferred stays local-only)",
1936	                    target
1937	                );
1938	                return 1;
1939	            }
1940	            // The WAN leg (D9-1-4): resolve against the registry snapshots
1941	            // and ship to the instance's node; its daemon delivers/spools.
1942	            // [impl->REQ-NET-1]
1943	            match crate::wansend::wan_send(&target, &from, &body) {
1944	                crate::wansend::WanSendOutcome::Sent { node } => {
1945	                    ok_line(format!(
1946	                        "{}:{}@{}",
1947	                        if is_reply {
1948	                            "REPLIED(WAN)"
1949	                        } else {
1950	                            "SENT(WAN)"
1951	                        },
1952	                        target,
1953	                        node
1954	                    ));
1955	                    0
1956	                }
1957	                crate::wansend::WanSendOutcome::Ambiguous(why) => {
1958	                    eprintln!("AMBIGUOUS:{target} — {why}");
1959	                    1
1960	                }
1961	                crate::wansend::WanSendOutcome::NotFound => {
1962	                    eprintln!("NO_PERCH:{} is not listening", target);
1963	                    1
1964	                }
1965	                crate::wansend::WanSendOutcome::Failed(e) => {
1966	                    eprintln!("WAN_FAIL:{target} — {e}");
1967	                    1
1968	                }
1969	            }
1970	        }
1971	        SendOutcome::Empty => {
1972	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
1973	            1
1974	        }
1975	    }
1976	}
1977
1978	/// Print a SUCCESS epilogue to stderr in legacy-spt blue. Epilogues live on
1979	/// stderr by contract (stdout carries message payloads), but PowerShell
1980	/// renders a native command's stderr red — success dressed as an error.
1981	/// Explicit color overrides that; a redirect/pipe (non-tty) or NO_COLOR gets
1982	/// plain text, so logs and machine consumers see the bare tag unchanged.
1983	/// `FORCE_COLOR` (any value but `0`) overrides the tty gate — captured
1984	/// contexts that still render ANSI (harness tool panes piping stderr) can
1985	/// opt back in. Failure lines stay uncolored — the host shell's red reads
1986	/// correctly there.
1987	fn ok_line(line: String) {
1988	    use std::io::IsTerminal;
1989	    let forced = std::env::var_os("FORCE_COLOR").is_some_and(|v| v != "0");
1990	    let auto = std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal();
1991	    if forced || auto {
1992	        eprintln!("\x1b[94m{line}\x1b[0m");
1993	    } else {
1994	        eprintln!("{line}");
1995	    }
1996	}
1997
1998	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
1999	    let from = resolve_from(from);
2000	    if from.is_empty() {
2001	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
2002	        return 1;
2003	    }
2004	    let body = read_stdin();

fn cmd_send(
    target: Option<String>,
    from: Option<String>,
    reply_to: Option<String>,
    deferred: bool,
) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            ok_line(format!(
                "{}:{}",
                if is_reply { "REPLIED" } else { "SENT" },
                target
            ));
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            ok_line(format!("{}:{}", label, target));
            0
        }
        SendOutcome::NoPerch => {
            if deferred {
                // The wire record carries no deferred class (v1) — a remote
                // deferred send is a documented seam, not a silent reclass.
                eprintln!(
                    "NO_PERCH:{} is not listening (deferred stays local-only)",
                    target
                );
                return 1;
            }
            // The WAN leg (D9-1-4): resolve against the registry snapshots
            // and ship to the instance's node; its daemon delivers/spools.
            // [impl->REQ-NET-1]
            match crate::wansend::wan_send(&target, &from, &body) {
                crate::wansend::WanSendOutcome::Sent { node } => {
                    ok_line(format!(
                        "{}:{}@{}",
                        if is_reply {
                            "REPLIED(WAN)"
                        } else {
                            "SENT(WAN)"
                        },
                        target,
                        node
                    ));
                    0
                }
                crate::wansend::WanSendOutcome::Ambiguous(why) => {
                    eprintln!("AMBIGUOUS:{target} — {why}");
                    1
                }
                crate::wansend::WanSendOutcome::NotFound => {
                    eprintln!("NO_PERCH:{} is not listening", target);
                    1
                }
                crate::wansend::WanSendOutcome::Failed(e) => {
                    eprintln!("WAN_FAIL:{target} — {e}");
                    1
                }
            }
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

/// Print a SUCCESS epilogue to stderr in legacy-spt blue. Epilogues live on
/// stderr by contract (stdout carries message payloads), but PowerShell
/// renders a native command's stderr red — success dressed as an error.
/// Explicit color overrides that; a redirect/pipe (non-tty) or NO_COLOR gets
/// plain text, so logs and machine consumers see the bare tag unchanged.
/// `FORCE_COLOR` (any value but `0`) overrides the tty gate — captured
/// contexts that still render ANSI (harness tool panes piping stderr) can
/// opt back in. Failure lines stay uncolored — the host shell's red reads
/// correctly there.
fn ok_line(line: String) {
    use std::io::IsTerminal;
    let forced = std::env::var_os("FORCE_COLOR").is_some_and(|v| v != "0");
    let auto = std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal();
    if forced || auto {
        eprintln!("\x1b[94m{line}\x1b[0m");
    } else {
        eprintln!("{line}");
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();


`cmd_send` line 1901: `read_stdin()` with **no trim** — unlike `cmd_notify` (line 1572 `.trim()`). Raw body with Windows `\r\n` ships to wire. Confirms rank-1.

Check the EVENT emit/escape side — does it sanitize `\r`?


1	//! Listener-stream EVENT rendering: raw wire/spool frame → the `<EVENT …>`
2	//! envelope lines a harness agent parses off the Monitor stream.
3	//!
4	//! Clean-room re-expression of the sister `owl/poll.rs` emit path
5	//! (`parse_tcp_message` → `assemble_event_line` → `chunk_if_oversized`); the
6	//! envelope **format** itself is the ADR-0001 copy-verbatim grammar already
7	//! housed in [`spt_proto::event`]. This module is the missing glue the M1
8	//! listener loops shipped without: they printed the raw
9	//! `__REPLY_TO__:{from}\n{body}` frame straight to stdout, so receiving
10	//! agents saw a bare body instead of `<EVENT type="msg" from="…">…</EVENT>`.
11	//!
12	//! Scope: the **listener stream** only (`spt ready` / `spt api listen` — the
13	//! `« spt event »` Monitor loops). Hook-channel drains (`spt api poll`,
14	//! `api worker-poll`) keep the raw frame by contract — their consumer is the
15	//! adapter's hook composer, not the event stream.
16	// [impl->REQ-MSG-4]
17
18	use spt_proto::chunk::{chunk_if_oversized, EVENT_LINE_THRESHOLD};
19	use spt_proto::event::{compose_msg_event, is_typed_event_envelope};
20
21	/// Parse a raw delivered frame into `(from, body)`.
22	/// Frame shape: `__REPLY_TO__:{from}\n{body}` or just `{body}` (anonymous —
23	/// `from` is `""`). Trailing newlines are trimmed off the body (the TCP wire
24	/// adds one; spool rows don't). Mirrors the sister's `parse_tcp_message`.
25	// [impl->REQ-MSG-4]
26	pub fn parse_frame(msg: &str) -> (String, String) {
27	    if let Some(rest) = msg.strip_prefix("__REPLY_TO__:") {
28	        if let Some(newline_pos) = rest.find('\n') {
29	            let from = rest[..newline_pos].to_string();
30	            let body = rest[newline_pos + 1..].trim_end_matches('\n').to_string();
31	            return (from, body);
32	        }
33	    }
34	    (String::new(), msg.trim_end_matches('\n').to_string())
35	}
36
37	/// Render one delivered frame into the stdout line(s) for the listener
38	/// stream, mirroring the sister's emit discipline:
39	///
40	/// 1. a body that is already a fully-formed typed `<EVENT …>…</EVENT>`
41	///    envelope (echo_commune, notify, …) passes through **verbatim** — never
42	///    re-wrapped (the sister's echo-commune-double-envelope fix);
43	/// 2. anything else composes `<EVENT type="msg" from="{from}">{body}</EVENT>`
44	///    (attr/body escaped; anonymous delivery renders `from=""`);
45	/// 3. the assembled line chunks into `<EVENT-PART …>` lines past
46	///    [`EVENT_LINE_THRESHOLD`] so the harness notification cap holds.
47	// [impl->REQ-MSG-4]
48	pub fn render_event_lines(msg: &str) -> Vec<String> {
49	    let (from, body) = parse_frame(msg);
50	    if is_typed_event_envelope(&body) {
51	        return vec![body];
52	    }
53	    let line = compose_msg_event(&from, &body);
54	    chunk_if_oversized(&line, EVENT_LINE_THRESHOLD)
55	}
56
57	#[cfg(test)]
58	mod tests {
59	    use super::*;
60	    use spt_proto::event::parse_event;
61
62	    // [unit->REQ-MSG-4] the regular frame renders the sister msg envelope:
63	    // from attribute + body, exactly one line.
64	    #[test]
65	    fn frame_renders_msg_envelope() {
66	        let lines = render_event_lines("__REPLY_TO__:lea\ntest message, hi!\n");
67	        assert_eq!(
68	            lines,
69	            vec![r#"<EVENT type="msg" from="lea">test message, hi!</EVENT>"#.to_string()]
70	        );
71	    }
72
73	    // [unit->REQ-MSG-4] anonymous frame (no __REPLY_TO__ line) still gets the
74	    // envelope — from="" — never a bare body on the stream.
75	    #[test]
76	    fn anonymous_frame_still_enveloped() {
77	        let lines = render_event_lines("no header here\n");
78	        assert_eq!(
79	            lines,
80	            vec![r#"<EVENT type="msg" from="">no header here</EVENT>"#.to_string()]
81	        );
82	    }
83
84	    // [unit->REQ-MSG-4] multi-line bodies escape (`\n` → `<br>`) and round-trip
85	    // through the receiver-side parser.
86	    #[test]
87	    fn multiline_body_escapes_and_round_trips() {
88	        let lines = render_event_lines("__REPLY_TO__:bob\nline one\nline two\n");
89	        assert_eq!(lines.len(), 1);
90	        assert!(lines[0].contains("line one<br>line two"));
91	        let p = parse_event(&lines[0]).unwrap();
92	        assert_eq!(p.from(), Some("bob"));
93	        assert_eq!(p.body, "line one\nline two");
94	    }
95
96	    // [unit->REQ-MSG-4] a pre-formed typed envelope body (e.g. the daemon's
97	    // spooled notify rows) passes through verbatim — no outer msg double-wrap.
98	    #[test]
99	    fn typed_envelope_body_passes_through_verbatim() {
100	        let typed = r#"<EVENT type="notify" from="issuer" notif_id="cafe:1">build done</EVENT>"#;
101	        let frame = format!("__REPLY_TO__:issuer\n{typed}");
102	        assert_eq!(render_event_lines(&frame), vec![typed.to_string()]);
103	    }
104
105	    // [unit->REQ-MSG-4] an oversized assembled line chunks into EVENT-PART
106	    // lines that reassemble byte-exact.
107	    #[test]
108	    fn oversized_line_chunks_into_event_parts() {
109	        let body = "x".repeat(1500);
110	        let frame = format!("__REPLY_TO__:doyle\n{body}");
111	        let lines = render_event_lines(&frame);
112	        assert!(lines.len() > 1, "1500-char body must chunk");
113	        assert!(lines.iter().all(|l| l.starts_with("<EVENT-PART ")));
114	        let whole = spt_proto::chunk::reassemble_group(&lines).expect("reassembles");
115	        let p = parse_event(&whole).unwrap();
116	        assert_eq!(p.from(), Some("doyle"));
117	        assert_eq!(p.body, body);
118	    }
119
120	    // [unit->REQ-MSG-4] parse_frame splits header/body and trims only
121	    // trailing newlines.
122	    #[test]
123	    fn parse_frame_shapes() {
124	        assert_eq!(
125	            parse_frame("__REPLY_TO__:a\nb\n"),
126	            ("a".to_string(), "b".to_string())
127	        );
128	        assert_eq!(parse_frame("bare\n"), (String::new(), "bare".to_string()));
129	        // Header with multi-line body keeps interior newlines.
130	        assert_eq!(
131	            parse_frame("__REPLY_TO__:a\nx\ny\n"),
132	            ("a".to_string(), "x\ny".to_string())
133	        );
134	    }
135	}
136

//! Listener-stream EVENT rendering: raw wire/spool frame → the `<EVENT …>`
//! envelope lines a harness agent parses off the Monitor stream.
//!
//! Clean-room re-expression of the sister `owl/poll.rs` emit path
//! (`parse_tcp_message` → `assemble_event_line` → `chunk_if_oversized`); the
//! envelope **format** itself is the ADR-0001 copy-verbatim grammar already
//! housed in [`spt_proto::event`]. This module is the missing glue the M1
//! listener loops shipped without: they printed the raw
//! `__REPLY_TO__:{from}\n{body}` frame straight to stdout, so receiving
//! agents saw a bare body instead of `<EVENT type="msg" from="…">…</EVENT>`.
//!
//! Scope: the **listener stream** only (`spt ready` / `spt api listen` — the
//! `« spt event »` Monitor loops). Hook-channel drains (`spt api poll`,
//! `api worker-poll`) keep the raw frame by contract — their consumer is the
//! adapter's hook composer, not the event stream.
// [impl->REQ-MSG-4]

use spt_proto::chunk::{chunk_if_oversized, EVENT_LINE_THRESHOLD};
use spt_proto::event::{compose_msg_event, is_typed_event_envelope};

/// Parse a raw delivered frame into `(from, body)`.
/// Frame shape: `__REPLY_TO__:{from}\n{body}` or just `{body}` (anonymous —
/// `from` is `""`). Trailing newlines are trimmed off the body (the TCP wire
/// adds one; spool rows don't). Mirrors the sister's `parse_tcp_message`.
// [impl->REQ-MSG-4]
pub fn parse_frame(msg: &str) -> (String, String) {
    if let Some(rest) = msg.strip_prefix("__REPLY_TO__:") {
        if let Some(newline_pos) = rest.find('\n') {
            let from = rest[..newline_pos].to_string();
            let body = rest[newline_pos + 1..].trim_end_matches('\n').to_string();
            return (from, body);
        }
    }
    (String::new(), msg.trim_end_matches('\n').to_string())
}

/// Render one delivered frame into the stdout line(s) for the listener
/// stream, mirroring the sister's emit discipline:
///
/// 1. a body that is already a fully-formed typed `<EVENT …>…</EVENT>`
///    envelope (echo_commune, notify, …) passes through **verbatim** — never
///    re-wrapped (the sister's echo-commune-double-envelope fix);
/// 2. anything else composes `<EVENT type="msg" from="{from}">{body}</EVENT>`
///    (attr/body escaped; anonymous delivery renders `from=""`);
/// 3. the assembled line chunks into `<EVENT-PART …>` lines past
///    [`EVENT_LINE_THRESHOLD`] so the harness notification cap holds.
// [impl->REQ-MSG-4]
pub fn render_event_lines(msg: &str) -> Vec<String> {
    let (from, body) = parse_frame(msg);
    if is_typed_event_envelope(&body) {
        return vec![body];
    }
    let line = compose_msg_event(&from, &body);
    chunk_if_oversized(&line, EVENT_LINE_THRESHOLD)
}

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

    // [unit->REQ-MSG-4] the regular frame renders the sister msg envelope:
    // from attribute + body, exactly one line.
    #[test]
    fn frame_renders_msg_envelope() {
        let lines = render_event_lines("__REPLY_TO__:lea\ntest message, hi!\n");
        assert_eq!(
            lines,
            vec![r#"<EVENT type="msg" from="lea">test message, hi!</EVENT>"#.to_string()]
        );
    }

    // [unit->REQ-MSG-4] anonymous frame (no __REPLY_TO__ line) still gets the
    // envelope — from="" — never a bare body on the stream.
    #[test]
    fn anonymous_frame_still_enveloped() {
        let lines = render_event_lines("no header here\n");
        assert_eq!(
            lines,
            vec![r#"<EVENT type="msg" from="">no header here</EVENT>"#.to_string()]
        );
    }

    // [unit->REQ-MSG-4] multi-line bodies escape (`\n` → `<br>`) and round-trip
    // through the receiver-side parser.
    #[test]
    fn multiline_body_escapes_and_round_trips() {
        let lines = render_event_lines("__REPLY_TO__:bob\nline one\nline two\n");
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains("line one<br>line two"));
        let p = parse_event(&lines[0]).unwrap();
        assert_eq!(p.from(), Some("bob"));
        assert_eq!(p.body, "line one\nline two");
    }

    // [unit->REQ-MSG-4] a pre-formed typed envelope body (e.g. the daemon's
    // spooled notify rows) passes through verbatim — no outer msg double-wrap.
    #[test]
    fn typed_envelope_body_passes_through_verbatim() {
        let typed = r#"<EVENT type="notify" from="issuer" notif_id="cafe:1">build done</EVENT>"#;
        let frame = format!("__REPLY_TO__:issuer\n{typed}");
        assert_eq!(render_event_lines(&frame), vec![typed.to_string()]);
    }

    // [unit->REQ-MSG-4] an oversized assembled line chunks into EVENT-PART
    // lines that reassemble byte-exact.
    #[test]
    fn oversized_line_chunks_into_event_parts() {
        let body = "x".repeat(1500);
        let frame = format!("__REPLY_TO__:doyle\n{body}");
        let lines = render_event_lines(&frame);
        assert!(lines.len() > 1, "1500-char body must chunk");
        assert!(lines.iter().all(|l| l.starts_with("<EVENT-PART ")));
        let whole = spt_proto::chunk::reassemble_group(&lines).expect("reassembles");
        let p = parse_event(&whole).unwrap();
        assert_eq!(p.from(), Some("doyle"));
        assert_eq!(p.body, body);
    }

    // [unit->REQ-MSG-4] parse_frame splits header/body and trims only
    // trailing newlines.
    #[test]
    fn parse_frame_shapes() {
        assert_eq!(
            parse_frame("__REPLY_TO__:a\nb\n"),
            ("a".to_string(), "b".to_string())
        );
        assert_eq!(parse_frame("bare\n"), (String::new(), "bare".to_string()));
        // Header with multi-line body keeps interior newlines.
        assert_eq!(
            parse_frame("__REPLY_TO__:a\nx\ny\n"),
            ("a".to_string(), "x\ny".to_string())
        );
    }
}


/diagnose latest spt is running subnet SPT_DEV across 3 nodes. i sent a message between hfenduleam and enlyzeam. the message envelope came out malformed on the other side. sent: [Image #1] received: […


Root cause located. `parse_frame` line 30 & 34: `.trim_end_matches('\n')` — strips `\n` only, **leaves lone `\r`**. Windows body `hey...11!\r\n` → `hey...11!\r`. Then `compose_msg_event` wraps it `<EVENT ...>hey...11!\r</EVENT>`. The `\r` survives to the receiver terminal → carriage-return overwrite. Exact match.

Two layers both leak `\r`:
1. `cmd_send`/`cmd_ring` — no trim (notify trims, they don't).
2. `parse_frame` — trims `\n`, not `\r`.

Check the escape in `compose_msg_event` — does body escaping handle `\r`? That's the robust seam (interior CRLF in multi-line Windows messages would still corrupt even after end-trim).


1	//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
2	//! wire line. The **format** is copied verbatim from `claude_skill_owl`
3	//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
4	//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
5	//! wire contract. The general parser is the receiver-side inverse of those
6	//! compose sites (the sister parses receiver-side in the harness/agent layer,
7	//! per the runtime reorientation contract — we make it a first-class function
8	//! here).
9	//!
10	//! ## Wire shapes
11	//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
12	//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
13	//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
14	//!
15	//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
16	//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
17	//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
18	//! the source; [`parse_event`] decodes once after framing-parse — never decode
19	//! the full envelope string (that would unescape outer framing tokens too).
20
21	use crate::envelope::{
22	    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
23	};
24
25	// ---------------------------------------------------------------------------
26	// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
27	// ---------------------------------------------------------------------------
28
29	/// A regular agent-to-agent message.
30	pub const EVENT_TYPE_MSG: &str = "msg";
31	/// A self-originated timed alarm.
32	pub const EVENT_TYPE_ALARM: &str = "alarm";
33	/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
34	pub const EVENT_TYPE_COMMUNE: &str = "commune";
35	/// A file-drop notification envelope.
36	pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
37	/// An echo-commune brief (Psyche → Self FYI).
38	pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
39	/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
40	/// sender of the inbound being answered, and nowhere else (ADR-0012).
41	pub const EVENT_TYPE_REPLY: &str = "reply";
42	/// A Psyche-authored notify intent — routed by the daemon to the agent's own
43	/// user/subnet only (ADR-0012; D8's Psyche-producer path).
44	pub const EVENT_TYPE_NOTIFY: &str = "notify";
45	/// A shell command frame (agent→shell, the durable command channel — CONTEXT
46	/// §Shell model): `op` attr names the capability; the body is a JSON object of
47	/// the op's named arguments.
48	pub const EVENT_TYPE_SHELL_COMMAND: &str = "shell_command";
49	/// A shell text payload (agent→shell direction of the durable 2-way text+file
50	/// channel; the shell→agent direction rides the ordinary `msg` envelope).
51	pub const EVENT_TYPE_SHELL_TEXT: &str = "shell_text";
52	/// A shell file-transfer notice (durable text+file channel): `xfer-id` keys
53	/// the progress-queryable transfer record; `path` is where the blob landed.
54	pub const EVENT_TYPE_SHELL_FILE: &str = "shell_file";
55	/// A shell sensory payload (shell→agent, REST-only — never spooled, dropped
56	/// with a diagnostic unless the owner is live): `sensory-type` carries the
57	/// manifest-declared type.
58	pub const EVENT_TYPE_SENSORY: &str = "sensory";
59	/// A shell pre-close instruction (machinery, fired on link-break — CONTEXT
60	/// §Shell model lifecycle): the body is the manifest's `pre_close` string.
61	/// Not vocabulary-checked — the vocabulary bounds what an *agent* may ask;
62	/// a manifest instructing its own binary is its own authority.
63	pub const EVENT_TYPE_SHELL_CLOSE: &str = "shell_close";
64
65	/// The only event types a sandboxed Psyche may author on its stdout outbound
66	/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
67	/// stdout — including a `commune` (the Summarizer's inbound delta, never a
68	/// Psyche output) — is not a relayable intent and must be ignored by the
69	/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
70	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
71	pub fn is_psyche_authorable_type(event_type: &str) -> bool {
72	    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
73	}
74
75	// ---------------------------------------------------------------------------
76	// Compose (verbatim format — sister `assemble_event_line` branches)
77	// ---------------------------------------------------------------------------
78
79	/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
80	/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
81	/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
82	// [impl->REQ-ARCH-4]
83	pub fn compose_msg_event(from: &str, body: &str) -> String {
84	    format!(
85	        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
86	        event_attr_escape(from),
87	        event_body_escape(body)
88	    )
89	}
90
91	/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
92	/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
93	/// Attr order is preserved (type first, then the supplied pairs). This is the
94	/// general composer the payload layer (typed commands / blobs) builds on;
95	/// [`compose_msg_event`] is the `type="msg"` special case.
96	// [impl->REQ-ARCH-4]
97	pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
98	    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
99	    for (k, v) in attrs {
100	        s.push(' ');
101	        s.push_str(k);
102	        s.push_str("=\"");
103	        s.push_str(&event_attr_escape(v));
104	        s.push('"');
105	    }
106	    s.push('>');
107	    s.push_str(&event_body_escape(body));
108	    s.push_str("</EVENT>");
109	    s
110	}
111
112	/// Compose a self-originated alarm envelope:
113	/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
114	/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
115	/// body-escaped. Mirrors the sister's alarm branch.
116	// [impl->REQ-ARCH-4]
117	pub fn compose_alarm_event(target_iso: &str, current_iso: &str, body: &str) -> String {
118	    format!(
119	        "<EVENT type=\"alarm\" target-time=\"{}\" current-time=\"{}\">{}</EVENT>",
120	        event_attr_escape(target_iso),
121	        event_attr_escape(current_iso),
122	        event_body_escape(body)
123	    )
124	}
125
126	/// Returns true when `body` is already a fully-formed typed
127	/// `<EVENT ...>...</EVENT>` envelope and must be passed through verbatim — NOT
128	/// re-wrapped inside an outer `<EVENT type="msg" ...>`. Copied verbatim from
129	/// the sister's `body_is_typed_event_envelope` (echo-commune-double-envelope
130	/// fix). Cheap byte-slice check; no XML parse.
131	// [impl->REQ-ARCH-4]
132	pub fn is_typed_event_envelope(body: &str) -> bool {
133	    body.starts_with("<EVENT ") && body.ends_with("</EVENT>")
134	}
135
136	// ---------------------------------------------------------------------------
137	// Parse (receiver-side inverse — panic-free, malformed → None)
138	// ---------------------------------------------------------------------------
139
140	/// A parsed EVENT envelope. `event_type` is the `type="..."` attr if present
141	/// (typed envelopes always carry one; a bare `<EVENT>` would not). `attrs`
142	/// preserves every `key="value"` pair in wire order, values decoded amp-last.
143	/// `body` is the decoded body (`<br>`→`\n`, entities reversed amp-last).
144	#[derive(Debug, Clone, PartialEq, Eq)]
145	pub struct ParsedEvent {
146	    pub event_type: Option<String>,
147	    pub attrs: Vec<(String, String)>,
148	    pub body: String,
149	}
150
151	impl ParsedEvent {
152	    /// Look up the first decoded attribute value for `key`.
153	    pub fn attr(&self, key: &str) -> Option<&str> {
154	        self.attrs
155	            .iter()
156	            .find(|(k, _)| k == key)
157	            .map(|(_, v)| v.as_str())
158	    }
159
160	    /// Convenience: the `from` attribute, if any.
161	    pub fn from(&self) -> Option<&str> {
162	        self.attr("from")
163	    }
164	}
165
166	/// Parse a single `<EVENT attrs>body</EVENT>` wire line into a [`ParsedEvent`].
167	///
168	/// Never panics — every step is `Option`-based; returns `None` on any
169	/// malformed input: missing `<EVENT` open, missing `>` closing the open tag,
170	/// or missing trailing `</EVENT>`. Attribute values and the body are decoded
171	/// with the amp-last invariant.
172	///
173	/// Framing robustness: because attr values and the body are HTML-escaped at
174	/// compose time (`>` → `>`), the first raw `>` after `<EVENT` reliably ends
175	/// the open tag and the trailing `</EVENT>` reliably ends the body — neither
176	/// token can appear raw inside escaped content.
177	///
178	/// This parses ONE envelope line; long bodies that arrive as multiple
179	/// `<EVENT-PART seq=K/M id=...>` lines are reassembled by the T3 chunker first,
180	/// then the reassembled `<EVENT>` is parsed here.
181	// [impl->REQ-ARCH-4]
182	pub fn parse_event(line: &str) -> Option<ParsedEvent> {
183	    let line = line.trim();
184	    // Must be an EVENT (not EVENT-PART — we never parse a chunk as a whole).
185	    let after_open = line.strip_prefix("<EVENT")?;
186	    // Reject `<EVENT-PART ...>` (strip_prefix("<EVENT") leaves "-PART ...").
187	    if after_open.starts_with('-') {
188	        return None;
189	    }
190	    // The open tag is `<EVENT{attrs}>`; first raw `>` closes it (escaped
191	    // content can't contain a raw `>`).
192	    let gt = after_open.find('>')?;
193	    let attr_str = &after_open[..gt];
194	    let rest = &after_open[gt + 1..];
195	    // Body is everything up to the trailing `</EVENT>`.
196	    let body_escaped = rest.strip_suffix("</EVENT>")?;
197
198	    let attrs = parse_attrs(attr_str);
199	    let event_type = attrs
200	        .iter()
201	        .find(|(k, _)| k == "type")
202	        .map(|(_, v)| v.clone());
203
204	    Some(ParsedEvent {
205	        event_type,
206	        attrs,
207	        body: event_body_unescape(body_escaped),
208	    })
209	}
210
211	/// Parse an attribute-segment (`type="msg" from="a&b"`) into ordered
212	/// (key, decoded-value) pairs. Tolerant: skips anything that isn't a
213	/// `key="value"` pair; never panics. Values are decoded amp-last. Because
214	/// values are attr-escaped at compose time, no value can contain a raw `"`.
215	fn parse_attrs(s: &str) -> Vec<(String, String)> {
216	    let mut out = Vec::new();
217	    let mut rest = s;
218	    while let Some(eq) = rest.find("=\"") {
219	        // Key = the run of attr-name chars immediately before `="`.
220	        let key_region = &rest[..eq];
221	        let key_start = key_region.rfind([' ', '\t']).map(|i| i + 1).unwrap_or(0);
222	        let key = key_region[key_start..].trim();
223	        let after_eq = &rest[eq + 2..];
224	        let Some(close) = after_eq.find('"') else {
225	            break; // unterminated value — stop, drop the trailing garbage
226	        };
227	        let value = &after_eq[..close];
228	        if !key.is_empty() {
229	            out.push((key.to_string(), event_attr_unescape(value)));
230	        }
231	        rest = &after_eq[close + 1..];
232	    }
233	    out
234	}
235
236	#[cfg(test)]
237	mod tests {
238	    use super::*;
239
240	    // ---- compose → parse round-trip ----
241
242	    // [unit->REQ-ARCH-4]
243	    #[test]
244	    fn msg_event_round_trips() {
245	        let line = compose_msg_event("alice", "hello\nworld < & >");
246	        let p = parse_event(&line).expect("parse");
247	        assert_eq!(p.event_type.as_deref(), Some("msg"));
248	        assert_eq!(p.from(), Some("alice"));
249	        assert_eq!(p.body, "hello\nworld < & >");
250	    }
251
252	    // [unit->REQ-ARCH-4]
253	    #[test]
254	    fn alarm_event_round_trips() {
255	        let line = compose_alarm_event("2026-05-31T12:00:00Z", "2026-05-31T12:00:05Z", "wake up");
256	        let p = parse_event(&line).expect("parse");
257	        assert_eq!(p.event_type.as_deref(), Some("alarm"));
258	        assert_eq!(p.attr("target-time"), Some("2026-05-31T12:00:00Z"));
259	        assert_eq!(p.attr("current-time"), Some("2026-05-31T12:00:05Z"));
260	        assert_eq!(p.body, "wake up");
261	    }
262
263	    // [unit->REQ-ARCH-4] amp-last in attr decode: a literal `<` in `from`
264	    // must NOT double-decode into `<`.
265	    #[test]
266	    fn attr_decode_is_amp_last() {
267	        let line = compose_msg_event("<weird>", "x");
268	        // On the wire the `&` became `&`: from="&lt;weird&gt;".
269	        assert!(line.contains("from=\"&lt;weird&gt;\""));
270	        let p = parse_event(&line).unwrap();
271	        assert_eq!(p.from(), Some("<weird>"));
272	    }
273
274	    // [unit->REQ-ARCH-4] a literal `</EVENT>` / `<EVENT ...>` inside a body must
275	    // survive the framing round-trip (escaped at compose, not re-framed).
276	    #[test]
277	    fn body_with_literal_event_tokens_round_trips() {
278	        let nasty = "try </EVENT> and <EVENT type='inner'>fake</EVENT> in prose";
279	        let line = compose_msg_event("bob", nasty);
280	        // Escaped: no raw framing tokens leaked into the body region.
281	        assert_eq!(line.matches("</EVENT>").count(), 1, "only the real closer");
282	        let p = parse_event(&line).unwrap();
283	        assert_eq!(p.body, nasty);
284	    }
285
286	    // [unit->REQ-ARCH-4] typed envelopes are pass-through and re-parse cleanly.
287	    #[test]
288	    fn typed_envelope_detected_and_parsed() {
289	        let typed =
290	            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
291	        assert!(is_typed_event_envelope(typed));
292	        let p = parse_event(typed).unwrap();
293	        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
294	        assert_eq!(p.from(), Some("doyle-psyche"));
295	        assert_eq!(p.attr("note"), Some("brief"));
296	        assert_eq!(p.body, "hello world");
297	    }
298
299	    // [unit->REQ-ARCH-4] attr order is preserved.
300	    #[test]
301	    fn attr_order_preserved() {
302	        let p =
303	            parse_event(r#"<EVENT type="msg" from="a" seq="1/2" id="deadbeef">b</EVENT>"#).unwrap();
304	        let keys: Vec<&str> = p.attrs.iter().map(|(k, _)| k.as_str()).collect();
305	        assert_eq!(keys, vec!["type", "from", "seq", "id"]);
306	    }
307
308	    // ---- malformed → None, never panic ----
309
310	    // [unit->REQ-ARCH-4]
311	    #[test]
312	    fn malformed_inputs_return_none_without_panic() {
313	        assert_eq!(parse_event("not an event"), None);
314	        assert_eq!(
315	            parse_event("<EVENT type=\"msg\" from=\"a\">no closer"),
316	            None
317	        );
318	        assert_eq!(parse_event("<EVENT unterminated attr"), None);
319	        // EVENT-PART is a chunk, not a whole envelope — must not parse here.
320	        assert_eq!(
321	            parse_event(r#"<EVENT-PART seq="1/2" id="ab">chunk</EVENT-PART>"#),
322	            None
323	        );
324	        assert_eq!(parse_event(""), None);
325	    }
326
327	    // [unit->REQ-ARCH-4] empty-body envelope (file_drop shape) parses to "".
328	    #[test]
329	    fn empty_body_envelope_parses() {
330	        let p = parse_event(r#"<EVENT type="file_drop" path="x" from="d"></EVENT>"#).unwrap();
331	        assert_eq!(p.event_type.as_deref(), Some("file_drop"));
332	        assert_eq!(p.body, "");
333	        assert_eq!(p.attr("path"), Some("x"));
334	    }
335
336	    // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] only reply/notify are
337	    // Psyche-authorable; commune (Summarizer inbound) and the rest are not.
338	    #[test]
339	    fn psyche_authorable_types_are_reply_and_notify_only() {
340	        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));
341	        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));
342	        for t in [
343	            EVENT_TYPE_MSG,
344	            EVENT_TYPE_ALARM,
345	            EVENT_TYPE_COMMUNE,
346	            EVENT_TYPE_FILE_DROP,
347	            EVENT_TYPE_ECHO_COMMUNE,
348	            "",
349	            "Reply",
350	        ] {
351	            assert!(
352	                !is_psyche_authorable_type(t),
353	                "{t:?} must not be authorable"
354	            );
355	        }
356	    }
357
358	    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
359	    #[test]
360	    fn typed_predicate_rejects_partials() {
361	        assert!(!is_typed_event_envelope("<EVENT type=\"x\" no close"));
362	        assert!(!is_typed_event_envelope("plain body</EVENT>"));
363	        assert!(!is_typed_event_envelope("plain body"));
364	    }
365	}
366

//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
//! wire line. The **format** is copied verbatim from `claude_skill_owl`
//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
//! wire contract. The general parser is the receiver-side inverse of those
//! compose sites (the sister parses receiver-side in the harness/agent layer,
//! per the runtime reorientation contract — we make it a first-class function
//! here).
//!
//! ## Wire shapes
//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
//!
//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
//! the source; [`parse_event`] decodes once after framing-parse — never decode
//! the full envelope string (that would unescape outer framing tokens too).

use crate::envelope::{
    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
};

// ---------------------------------------------------------------------------
// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
// ---------------------------------------------------------------------------

/// A regular agent-to-agent message.
pub const EVENT_TYPE_MSG: &str = "msg";
/// A self-originated timed alarm.
pub const EVENT_TYPE_ALARM: &str = "alarm";
/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
pub const EVENT_TYPE_COMMUNE: &str = "commune";
/// A file-drop notification envelope.
pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
/// An echo-commune brief (Psyche → Self FYI).
pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
/// sender of the inbound being answered, and nowhere else (ADR-0012).
pub const EVENT_TYPE_REPLY: &str = "reply";
/// A Psyche-authored notify intent — routed by the daemon to the agent's own
/// user/subnet only (ADR-0012; D8's Psyche-producer path).
pub const EVENT_TYPE_NOTIFY: &str = "notify";
/// A shell command frame (agent→shell, the durable command channel — CONTEXT
/// §Shell model): `op` attr names the capability; the body is a JSON object of
/// the op's named arguments.
pub const EVENT_TYPE_SHELL_COMMAND: &str = "shell_command";
/// A shell text payload (agent→shell direction of the durable 2-way text+file
/// channel; the shell→agent direction rides the ordinary `msg` envelope).
pub const EVENT_TYPE_SHELL_TEXT: &str = "shell_text";
/// A shell file-transfer notice (durable text+file channel): `xfer-id` keys
/// the progress-queryable transfer record; `path` is where the blob landed.
pub const EVENT_TYPE_SHELL_FILE: &str = "shell_file";
/// A shell sensory payload (shell→agent, REST-only — never spooled, dropped
/// with a diagnostic unless the owner is live): `sensory-type` carries the
/// manifest-declared type.
pub const EVENT_TYPE_SENSORY: &str = "sensory";
/// A shell pre-close instruction (machinery, fired on link-break — CONTEXT
/// §Shell model lifecycle): the body is the manifest's `pre_close` string.
/// Not vocabulary-checked — the vocabulary bounds what an *agent* may ask;
/// a manifest instructing its own binary is its own authority.
pub const EVENT_TYPE_SHELL_CLOSE: &str = "shell_close";

/// The only event types a sandboxed Psyche may author on its stdout outbound
/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
/// stdout — including a `commune` (the Summarizer's inbound delta, never a
/// Psyche output) — is not a relayable intent and must be ignored by the
/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn is_psyche_authorable_type(event_type: &str) -> bool {
    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
}

// ---------------------------------------------------------------------------
// Compose (verbatim format — sister `assemble_event_line` branches)
// ---------------------------------------------------------------------------

/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
// [impl->REQ-ARCH-4]
pub fn compose_msg_event(from: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
        event_attr_escape(from),
        event_body_escape(body)
    )
}

/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
/// Attr order is preserved (type first, then the supplied pairs). This is the
/// general composer the payload layer (typed commands / blobs) builds on;
/// [`compose_msg_event`] is the `type="msg"` special case.
// [impl->REQ-ARCH-4]
pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
    for (k, v) in attrs {
        s.push(' ');
        s.push_str(k);
        s.push_str("=\"");
        s.push_str(&event_attr_escape(v));
        s.push('"');
    }
    s.push('>');
    s.push_str(&event_body_escape(body));
    s.push_str("</EVENT>");
    s
}

/// Compose a self-originated alarm envelope:
/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
/// body-escaped. Mirrors the sister's alarm branch.
// [impl->REQ-ARCH-4]
pub fn compose_alarm_event(target_iso: &str, current_iso: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"alarm\" target-time=\"{}\" current-time=\"{}\">{}</EVENT>",
        event_attr_escape(target_iso),
        event_attr_escape(current_iso),
        event_body_escape(body)
    )
}

/// Returns true when `body` is already a fully-formed typed
/// `<EVENT ...>...</EVENT>` envelope and must be passed through verbatim — NOT
/// re-wrapped inside an outer `<EVENT type="msg" ...>`. Copied verbatim from
/// the sister's `body_is_typed_event_envelope` (echo-commune-double-envelope
/// fix). Cheap byte-slice check; no XML parse.
// [impl->REQ-ARCH-4]
pub fn is_typed_event_envelope(body: &str) -> bool {
    body.starts_with("<EVENT ") && body.ends_with("</EVENT>")
}

// ---------------------------------------------------------------------------
// Parse (receiver-side inverse — panic-free, malformed → None)
// ---------------------------------------------------------------------------

/// A parsed EVENT envelope. `event_type` is the `type="..."` attr if present
/// (typed envelopes always carry one; a bare `<EVENT>` would not). `attrs`
/// preserves every `key="value"` pair in wire order, values decoded amp-last.
/// `body` is the decoded body (`<br>`→`\n`, entities reversed amp-last).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedEvent {
    pub event_type: Option<String>,
    pub attrs: Vec<(String, String)>,
    pub body: String,
}

impl ParsedEvent {
    /// Look up the first decoded attribute value for `key`.
    pub fn attr(&self, key: &str) -> Option<&str> {
        self.attrs
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.as_str())
    }

    /// Convenience: the `from` attribute, if any.
    pub fn from(&self) -> Option<&str> {
        self.attr("from")
    }
}

/// Parse a single `<EVENT attrs>body</EVENT>` wire line into a [`ParsedEvent`].
///
/// Never panics — every step is `Option`-based; returns `None` on any
/// malformed input: missing `<EVENT` open, missing `>` closing the open tag,
/// or missing trailing `</EVENT>`. Attribute values and the body are decoded
/// with the amp-last invariant.
///
/// Framing robustness: because attr values and the body are HTML-escaped at
/// compose time (`>` → `>`), the first raw `>` after `<EVENT` reliably ends
/// the open tag and the trailing `</EVENT>` reliably ends the body — neither
/// token can appear raw inside escaped content.
///
/// This parses ONE envelope line; long bodies that arrive as multiple
/// `<EVENT-PART seq=K/M id=...>` lines are reassembled by the T3 chunker first,
/// then the reassembled `<EVENT>` is parsed here.
// [impl->REQ-ARCH-4]
pub fn parse_event(line: &str) -> Option<ParsedEvent> {
    let line = line.trim();
    // Must be an EVENT (not EVENT-PART — we never parse a chunk as a whole).
    let after_open = line.strip_prefix("<EVENT")?;
    // Reject `<EVENT-PART ...>` (strip_prefix("<EVENT") leaves "-PART ...").
    if after_open.starts_with('-') {
        return None;
    }
    // The open tag is `<EVENT{attrs}>`; first raw `>` closes it (escaped
    // content can't contain a raw `>`).
    let gt = after_open.find('>')?;
    let attr_str = &after_open[..gt];
    let rest = &after_open[gt + 1..];
    // Body is everything up to the trailing `</EVENT>`.
    let body_escaped = rest.strip_suffix("</EVENT>")?;

    let attrs = parse_attrs(attr_str);
    let event_type = attrs
        .iter()
        .find(|(k, _)| k == "type")
        .map(|(_, v)| v.clone());

    Some(ParsedEvent {
        event_type,
        attrs,
        body: event_body_unescape(body_escaped),
    })
}

/// Parse an attribute-segment (`type="msg" from="a&b"`) into ordered
/// (key, decoded-value) pairs. Tolerant: skips anything that isn't a
/// `key="value"` pair; never panics. Values are decoded amp-last. Because
/// values are attr-escaped at compose time, no value can contain a raw `"`.
fn parse_attrs(s: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    let mut rest = s;
    while let Some(eq) = rest.find("=\"") {
        // Key = the run of attr-name chars immediately before `="`.
        let key_region = &rest[..eq];
        let key_start = key_region.rfind([' ', '\t']).map(|i| i + 1).unwrap_or(0);
        let key = key_region[key_start..].trim();
        let after_eq = &rest[eq + 2..];
        let Some(close) = after_eq.find('"') else {
            break; // unterminated value — stop, drop the trailing garbage
        };
        let value = &after_eq[..close];
        if !key.is_empty() {
            out.push((key.to_string(), event_attr_unescape(value)));
        }
        rest = &after_eq[close + 1..];
    }
    out
}

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

    // ---- compose → parse round-trip ----

    // [unit->REQ-ARCH-4]
    #[test]
    fn msg_event_round_trips() {
        let line = compose_msg_event("alice", "hello\nworld < & >");
        let p = parse_event(&line).expect("parse");
        assert_eq!(p.event_type.as_deref(), Some("msg"));
        assert_eq!(p.from(), Some("alice"));
        assert_eq!(p.body, "hello\nworld < & >");
    }

    // [unit->REQ-ARCH-4]
    #[test]
    fn alarm_event_round_trips() {
        let line = compose_alarm_event("2026-05-31T12:00:00Z", "2026-05-31T12:00:05Z", "wake up");
        let p = parse_event(&line).expect("parse");
        assert_eq!(p.event_type.as_deref(), Some("alarm"));
        assert_eq!(p.attr("target-time"), Some("2026-05-31T12:00:00Z"));
        assert_eq!(p.attr("current-time"), Some("2026-05-31T12:00:05Z"));
        assert_eq!(p.body, "wake up");
    }

    // [unit->REQ-ARCH-4] amp-last in attr decode: a literal `<` in `from`
    // must NOT double-decode into `<`.
    #[test]
    fn attr_decode_is_amp_last() {
        let line = compose_msg_event("<weird>", "x");
        // On the wire the `&` became `&`: from="&lt;weird&gt;".
        assert!(line.contains("from=\"&lt;weird&gt;\""));
        let p = parse_event(&line).unwrap();
        assert_eq!(p.from(), Some("<weird>"));
    }

    // [unit->REQ-ARCH-4] a literal `</EVENT>` / `<EVENT ...>` inside a body must
    // survive the framing round-trip (escaped at compose, not re-framed).
    #[test]
    fn body_with_literal_event_tokens_round_trips() {
        let nasty = "try </EVENT> and <EVENT type='inner'>fake</EVENT> in prose";
        let line = compose_msg_event("bob", nasty);
        // Escaped: no raw framing tokens leaked into the body region.
        assert_eq!(line.matches("</EVENT>").count(), 1, "only the real closer");
        let p = parse_event(&line).unwrap();
        assert_eq!(p.body, nasty);
    }

    // [unit->REQ-ARCH-4] typed envelopes are pass-through and re-parse cleanly.
    #[test]
    fn typed_envelope_detected_and_parsed() {
        let typed =
            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
        assert!(is_typed_event_envelope(typed));
        let p = parse_event(typed).unwrap();
        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
        assert_eq!(p.from(), Some("doyle-psyche"));
        assert_eq!(p.attr("note"), Some("brief"));
        assert_eq!(p.body, "hello world");
    }

    // [unit->REQ-ARCH-4] attr order is preserved.
    #[test]
    fn attr_order_preserved() {
        let p =
            parse_event(r#"<EVENT type="msg" from="a" seq="1/2" id="deadbeef">b</EVENT>"#).unwrap();
        let keys: Vec<&str> = p.attrs.iter().map(|(k, _)| k.as_str()).collect();
        assert_eq!(keys, vec!["type", "from", "seq", "id"]);
    }

    // ---- malformed → None, never panic ----

    // [unit->REQ-ARCH-4]
    #[test]
    fn malformed_inputs_return_none_without_panic() {
        assert_eq!(parse_event("not an event"), None);
        assert_eq!(
            parse_event("<EVENT type=\"msg\" from=\"a\">no closer"),
            None
        );
        assert_eq!(parse_event("<EVENT unterminated attr"), None);
        // EVENT-PART is a chunk, not a whole envelope — must not parse here.
        assert_eq!(
            parse_event(r#"<EVENT-PART seq="1/2" id="ab">chunk</EVENT-PART>"#),
            None
        );
        assert_eq!(parse_event(""), None);
    }

    // [unit->REQ-ARCH-4] empty-body envelope (file_drop shape) parses to "".
    #[test]
    fn empty_body_envelope_parses() {
        let p = parse_event(r#"<EVENT type="file_drop" path="x" from="d"></EVENT>"#).unwrap();
        assert_eq!(p.event_type.as_deref(), Some("file_drop"));
        assert_eq!(p.body, "");
        assert_eq!(p.attr("path"), Some("x"));
    }

    // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] only reply/notify are
    // Psyche-authorable; commune (Summarizer inbound) and the rest are not.
    #[test]
    fn psyche_authorable_types_are_reply_and_notify_only() {
        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));
        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));
        for t in [
            EVENT_TYPE_MSG,
            EVENT_TYPE_ALARM,
            EVENT_TYPE_COMMUNE,
            EVENT_TYPE_FILE_DROP,
            EVENT_TYPE_ECHO_COMMUNE,
            "",
            "Reply",
        ] {
            assert!(
                !is_psyche_authorable_type(t),
                "{t:?} must not be authorable"
            );
        }
    }

    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
    #[test]
    fn typed_predicate_rejects_partials() {
        assert!(!is_typed_event_envelope("<EVENT type=\"x\" no close"));
        assert!(!is_typed_event_envelope("plain body</EVENT>"));
        assert!(!is_typed_event_envelope("plain body"));
    }
}


Body escape is `event_body_escape` (`\n`→`<br>`). Check whether it handles `\r`.


1	//! Envelope grammar: the HTML-entity transport codec and the two-slice
2	//! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
3	//! (`src/owl/poll.rs` codec + `src/common/envelope.rs` parser) per ADR-0001 —
4	//! this is the stable, public wire contract; do not clean-room it.
5	//!
6	//! ## Encoding contract (the one written-down rule)
7	//! Producer encodes once at the source; body stays encoded through transport
8	//! (spool / TCP / relay); consumer decodes once after framing-parse.
9	//!
10	//! ENCODE order: `&`→`&` FIRST, then `<` `>` `"`, then `\n`→`<br>` LAST.
11	//! DECODE order: `<br>`→`\n` FIRST, then `<` `>` `"`, then
12	//! `&`→`&` **LAST** — the amp-last invariant. If `&` decoded first, a
13	//! user-prose literal like `&lt;EVENT&gt;` would double-decode into
14	//! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)
15
16	// ---------------------------------------------------------------------------
17	// Transport codec (KNOWN-HAZARDS 4.1 — `&` decoded LAST)
18	// ---------------------------------------------------------------------------
19
20	/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
21	pub fn event_body_escape(s: &str) -> String {
22	    s.replace('&', "&")
23	        .replace('<', "<")
24	        .replace('>', ">")
25	        .replace('"', """)
26	        .replace('\n', "
")
27	}
28
29	/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
30	/// tag-shaped entities, with `&`→`&` LAST. Call ONLY after EVENT framing
31	/// has been parsed and the body substring extracted; never on the full
32	/// envelope string (that would unescape outer framing tokens too).
33	///
34	/// Amp-last invariant: if `&` were decoded first, a body like
35	/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
36	// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
37	pub fn event_body_unescape(s: &str) -> String {
38	    s.replace("
", "\n")
39	        .replace("<", "<")
40	        .replace(">", ">")
41	        .replace(""", "\"")
42	        .replace("&", "&") // amp-last (Pitfall 3 inverse)
43	}
44
45	/// HTML-escape for attribute values. Same order as [`event_body_escape`] but
46	/// omits `\n`→`<br>` (attr values are line-safe by construction).
47	pub fn event_attr_escape(s: &str) -> String {
48	    s.replace('&', "&")
49	        .replace('<', "<")
50	        .replace('>', ">")
51	        .replace('"', """)
52	}
53
54	/// Inverse of [`event_attr_escape`]: decode an attribute value with the
55	/// amp-last invariant (`&`→`&` LAST). Each non-amp entity is unique so
56	/// their relative order is free; only amp-last matters — an id like `&lt;`
57	/// must decode to the literal `<`, not double-decode into `<`.
58	pub fn event_attr_unescape(s: &str) -> String {
59	    s.replace("<", "<")
60	        .replace(">", ">")
61	        .replace(""", "\"")
62	        .replace("&", "&") // amp-last
63	}
64
65	/// Extract the `from="..."` attribute from an `<EVENT ... from="ATTR" ...>`
66	/// envelope, unescaping with the amp-last invariant. Returns `None` on: no
67	/// `<EVENT ` tag, no `from=` attribute, empty `from=""`, or no closing quote.
68	// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
69	pub fn parse_event_from_attr(msg: &str) -> Option<String> {
70	    let event_start = msg.find("<EVENT ")?;
71	    let after_event = &msg[event_start..];
72	    let from_marker = "from=\"";
73	    let from_pos = after_event.find(from_marker)?;
74	    let value_start = from_pos + from_marker.len();
75	    let rest = &after_event[value_start..];
76	    let close_quote = rest.find('"')?;
77	    let escaped = &rest[..close_quote];
78	    if escaped.is_empty() {
79	        return None;
80	    }
81	    // Order: non-amp entities first, & LAST.
82	    let unescaped = escaped
83	        .replace("<", "<")
84	        .replace(">", ">")
85	        .replace(""", "\"")
86	        .replace("&", "&");
87	    Some(unescaped)
88	}
89
90	// ---------------------------------------------------------------------------
91	// Two-slice payload parser (KNOWN-HAZARDS 4.2 — panic-free + tolerant)
92	// ---------------------------------------------------------------------------
93
94	/// The two-slice payload produced by [`parse_two_slice`]. `live` is the body
95	/// of `<live-context>` if present; `project` is the body of `<project-context>`
96	/// if present. Either or both may be `None`.
97	#[derive(Debug, Clone, PartialEq, Eq)]
98	pub struct TwoSlicePayload {
99	    pub live: Option<String>,
100	    pub project: Option<String>,
101	}
102
103	/// Parse a raw Psyche output string into the two-slice taxonomy. Never panics:
104	/// every extraction is `Option`-based. Robustness rules: both/either/neither
105	/// tag, unclosed tags, out-of-order tags, empty bodies, surrounding whitespace,
106	/// and nested unknown tags preserved verbatim.
107	// [impl->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
108	pub fn parse_two_slice(raw: &str) -> TwoSlicePayload {
109	    let live = extract_tag(raw, "live-context");
110	    let project = extract_tag(raw, "project-context");
111
112	    // Fallback: no tags at all → whole payload routes to the live slot (trimmed).
113	    if live.is_none() && project.is_none() {
114	        let trimmed = raw.trim();
115	        return TwoSlicePayload {
116	            live: Some(trimmed.to_string()),
117	            project: None,
118	        };
119	    }
120
121	    TwoSlicePayload { live, project }
122	}
123
124	/// Find the first `<{tag}>...</{tag}>` and return the trimmed body. `None` when
125	/// either delimiter is absent or the close does not follow the open.
126	fn extract_tag(haystack: &str, tag: &str) -> Option<String> {
127	    let open = format!("<{}>", tag);
128	    let close = format!("</{}>", tag);
129	    let open_idx = haystack.find(&open)?;
130	    let body_start = open_idx + open.len();
131	    let after_open = haystack.get(body_start..)?;
132	    let close_off = after_open.find(&close)?;
133	    let body = &after_open[..close_off];
134	    Some(body.trim().to_string())
135	}
136
137	#[cfg(test)]
138	mod tests {
139	    use super::*;
140
141	    // ---- codec: amp-last invariant (KNOWN-HAZARDS 4.1) ----
142
143	    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
144	    #[test]
145	    fn decode_is_amp_last_so_literal_entities_do_not_double_decode() {
146	        // User prose containing a literal entity reference for `<EVENT>`.
147	        // Encoded once, it must round-trip back to the literal text, NOT to a
148	        // live `<EVENT>` framing token.
149	        let user_prose = "<EVENT>"; // literally the five chars ampersand-l-t...
150	        let encoded = event_body_escape(user_prose);
151	        // `&` -> `&` first, so the ampersands become `&`:
152	        assert_eq!(encoded, "&lt;EVENT&gt;");
153	        let decoded = event_body_unescape(&encoded);
154	        assert_eq!(
155	            decoded, user_prose,
156	            "amp-last decode must NOT collapse &lt; into <",
157	        );
158	    }
159
160	    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
161	    #[test]
162	    fn body_escape_unescape_round_trips_html_and_newlines() {
163	        let original = "a  c & d \"e\"\nsecond line";
164	        let round = event_body_unescape(&event_body_escape(original));
165	        assert_eq!(round, original);
166	    }
167
168	    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
169	    #[test]
170	    fn from_attr_unescape_is_amp_last() {
171	        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
172	        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
173	    }
174
175	    #[test]
176	    fn from_attr_returns_none_on_garbage_or_empty() {
177	        assert_eq!(parse_event_from_attr("no event here"), None);
178	        assert_eq!(parse_event_from_attr("<EVENT from=\"\">x</EVENT>"), None);
179	        assert_eq!(parse_event_from_attr("<EVENT from=\"unterminated"), None);
180	    }
181
182	    // ---- parser: panic-free + tolerant (KNOWN-HAZARDS 4.2) ----
183
184	    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
185	    #[test]
186	    fn parser_both_tags_present() {
187	        let raw = "<live-context>\nagent slice\n</live-context>\n<project-context>\nproject slice\n</project-context>";
188	        let p = parse_two_slice(raw);
189	        assert_eq!(p.live.as_deref(), Some("agent slice"));
190	        assert_eq!(p.project.as_deref(), Some("project slice"));
191	    }
192
193	    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
194	    #[test]
195	    fn parser_no_tags_falls_back_whole_payload_to_live() {
196	        let p = parse_two_slice("  plain prose, no tags\n");
197	        assert_eq!(p.live.as_deref(), Some("plain prose, no tags"));
198	        assert_eq!(p.project, None);
199	    }
200
201	    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
202	    #[test]
203	    fn parser_unclosed_live_tag_does_not_scoop_project() {
204	        let raw =
205	            "<live-context>\noops no close\n<project-context>\nproject body\n</project-context>";
206	        let p = parse_two_slice(raw);
207	        assert_eq!(p.live, None);
208	        assert_eq!(p.project.as_deref(), Some("project body"));
209	    }
210
211	    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
212	    #[test]
213	    fn parser_out_of_order_tags_both_extracted() {
214	        let raw =
215	            "<project-context>P first</project-context>\n<live-context>L after</live-context>";
216	        let p = parse_two_slice(raw);
217	        assert_eq!(p.live.as_deref(), Some("L after"));
218	        assert_eq!(p.project.as_deref(), Some("P first"));
219	    }
220
221	    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
222	    #[test]
223	    fn parser_empty_body_is_some_empty_and_nested_tags_preserved() {
224	        let p = parse_two_slice("<live-context></live-context><project-context></project-context>");
225	        assert_eq!(p.live.as_deref(), Some(""));
226	        assert_eq!(p.project.as_deref(), Some(""));
227
228	        let nested = "<live-context>\nrole: x\nfoo\n</live-context>";
229	        let p2 = parse_two_slice(nested);
230	        assert_eq!(
231	            p2.live.as_deref(),
232	            Some("role: x\nfoo")
233	        );
234	    }
235	}
236

//! Envelope grammar: the HTML-entity transport codec and the two-slice
//! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
//! (`src/owl/poll.rs` codec + `src/common/envelope.rs` parser) per ADR-0001 —
//! this is the stable, public wire contract; do not clean-room it.
//!
//! ## Encoding contract (the one written-down rule)
//! Producer encodes once at the source; body stays encoded through transport
//! (spool / TCP / relay); consumer decodes once after framing-parse.
//!
//! ENCODE order: `&`→`&` FIRST, then `<` `>` `"`, then `\n`→`<br>` LAST.
//! DECODE order: `<br>`→`\n` FIRST, then `<` `>` `"`, then
//! `&`→`&` **LAST** — the amp-last invariant. If `&` decoded first, a
//! user-prose literal like `&lt;EVENT&gt;` would double-decode into
//! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)

// ---------------------------------------------------------------------------
// Transport codec (KNOWN-HAZARDS 4.1 — `&` decoded LAST)
// ---------------------------------------------------------------------------

/// HTML-escape an EVENT body. Order: amp-first, `<br>`-last.
pub fn event_body_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
        .replace('\n', "
")
}

/// Inverse of [`event_body_escape`]. Decode order MUST be `<br>` FIRST, then
/// tag-shaped entities, with `&`→`&` LAST. Call ONLY after EVENT framing
/// has been parsed and the body substring extracted; never on the full
/// envelope string (that would unescape outer framing tokens too).
///
/// Amp-last invariant: if `&` were decoded first, a body like
/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
pub fn event_body_unescape(s: &str) -> String {
    s.replace("
", "\n")
        .replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&") // amp-last (Pitfall 3 inverse)
}

/// HTML-escape for attribute values. Same order as [`event_body_escape`] but
/// omits `\n`→`<br>` (attr values are line-safe by construction).
pub fn event_attr_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "<")
        .replace('>', ">")
        .replace('"', """)
}

/// Inverse of [`event_attr_escape`]: decode an attribute value with the
/// amp-last invariant (`&`→`&` LAST). Each non-amp entity is unique so
/// their relative order is free; only amp-last matters — an id like `&lt;`
/// must decode to the literal `<`, not double-decode into `<`.
pub fn event_attr_unescape(s: &str) -> String {
    s.replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&") // amp-last
}

/// Extract the `from="..."` attribute from an `<EVENT ... from="ATTR" ...>`
/// envelope, unescaping with the amp-last invariant. Returns `None` on: no
/// `<EVENT ` tag, no `from=` attribute, empty `from=""`, or no closing quote.
// [impl->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
pub fn parse_event_from_attr(msg: &str) -> Option<String> {
    let event_start = msg.find("<EVENT ")?;
    let after_event = &msg[event_start..];
    let from_marker = "from=\"";
    let from_pos = after_event.find(from_marker)?;
    let value_start = from_pos + from_marker.len();
    let rest = &after_event[value_start..];
    let close_quote = rest.find('"')?;
    let escaped = &rest[..close_quote];
    if escaped.is_empty() {
        return None;
    }
    // Order: non-amp entities first, & LAST.
    let unescaped = escaped
        .replace("<", "<")
        .replace(">", ">")
        .replace(""", "\"")
        .replace("&", "&");
    Some(unescaped)
}

// ---------------------------------------------------------------------------
// Two-slice payload parser (KNOWN-HAZARDS 4.2 — panic-free + tolerant)
// ---------------------------------------------------------------------------

/// The two-slice payload produced by [`parse_two_slice`]. `live` is the body
/// of `<live-context>` if present; `project` is the body of `<project-context>`
/// if present. Either or both may be `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TwoSlicePayload {
    pub live: Option<String>,
    pub project: Option<String>,
}

/// Parse a raw Psyche output string into the two-slice taxonomy. Never panics:
/// every extraction is `Option`-based. Robustness rules: both/either/neither
/// tag, unclosed tags, out-of-order tags, empty bodies, surrounding whitespace,
/// and nested unknown tags preserved verbatim.
// [impl->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
pub fn parse_two_slice(raw: &str) -> TwoSlicePayload {
    let live = extract_tag(raw, "live-context");
    let project = extract_tag(raw, "project-context");

    // Fallback: no tags at all → whole payload routes to the live slot (trimmed).
    if live.is_none() && project.is_none() {
        let trimmed = raw.trim();
        return TwoSlicePayload {
            live: Some(trimmed.to_string()),
            project: None,
        };
    }

    TwoSlicePayload { live, project }
}

/// Find the first `<{tag}>...</{tag}>` and return the trimmed body. `None` when
/// either delimiter is absent or the close does not follow the open.
fn extract_tag(haystack: &str, tag: &str) -> Option<String> {
    let open = format!("<{}>", tag);
    let close = format!("</{}>", tag);
    let open_idx = haystack.find(&open)?;
    let body_start = open_idx + open.len();
    let after_open = haystack.get(body_start..)?;
    let close_off = after_open.find(&close)?;
    let body = &after_open[..close_off];
    Some(body.trim().to_string())
}

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

    // ---- codec: amp-last invariant (KNOWN-HAZARDS 4.1) ----

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn decode_is_amp_last_so_literal_entities_do_not_double_decode() {
        // User prose containing a literal entity reference for `<EVENT>`.
        // Encoded once, it must round-trip back to the literal text, NOT to a
        // live `<EVENT>` framing token.
        let user_prose = "<EVENT>"; // literally the five chars ampersand-l-t...
        let encoded = event_body_escape(user_prose);
        // `&` -> `&` first, so the ampersands become `&`:
        assert_eq!(encoded, "&lt;EVENT&gt;");
        let decoded = event_body_unescape(&encoded);
        assert_eq!(
            decoded, user_prose,
            "amp-last decode must NOT collapse &lt; into <",
        );
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn body_escape_unescape_round_trips_html_and_newlines() {
        let original = "a  c & d \"e\"\nsecond line";
        let round = event_body_unescape(&event_body_escape(original));
        assert_eq!(round, original);
    }

    // [unit->REQ-HAZARD-ENVELOPE-DECODE-ORDER]
    #[test]
    fn from_attr_unescape_is_amp_last() {
        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
    }

    #[test]
    fn from_attr_returns_none_on_garbage_or_empty() {
        assert_eq!(parse_event_from_attr("no event here"), None);
        assert_eq!(parse_event_from_attr("<EVENT from=\"\">x</EVENT>"), None);
        assert_eq!(parse_event_from_attr("<EVENT from=\"unterminated"), None);
    }

    // ---- parser: panic-free + tolerant (KNOWN-HAZARDS 4.2) ----

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_both_tags_present() {
        let raw = "<live-context>\nagent slice\n</live-context>\n<project-context>\nproject slice\n</project-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live.as_deref(), Some("agent slice"));
        assert_eq!(p.project.as_deref(), Some("project slice"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_no_tags_falls_back_whole_payload_to_live() {
        let p = parse_two_slice("  plain prose, no tags\n");
        assert_eq!(p.live.as_deref(), Some("plain prose, no tags"));
        assert_eq!(p.project, None);
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_unclosed_live_tag_does_not_scoop_project() {
        let raw =
            "<live-context>\noops no close\n<project-context>\nproject body\n</project-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live, None);
        assert_eq!(p.project.as_deref(), Some("project body"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_out_of_order_tags_both_extracted() {
        let raw =
            "<project-context>P first</project-context>\n<live-context>L after</live-context>";
        let p = parse_two_slice(raw);
        assert_eq!(p.live.as_deref(), Some("L after"));
        assert_eq!(p.project.as_deref(), Some("P first"));
    }

    // [unit->REQ-HAZARD-ENVELOPE-PARSER-SAFE]
    #[test]
    fn parser_empty_body_is_some_empty_and_nested_tags_preserved() {
        let p = parse_two_slice("<live-context></live-context><project-context></project-context>");
        assert_eq!(p.live.as_deref(), Some(""));
        assert_eq!(p.project.as_deref(), Some(""));

        let nested = "<live-context>\nrole: x\nfoo\n</live-context>";
        let p2 = parse_two_slice(nested);
        assert_eq!(
            p2.live.as_deref(),
            Some("role: x\nfoo")
        );
    }
}


Root cause **confirmed** through full source trace. `event_body_escape` (envelope.rs:21-27) escapes `& < > "` and `\n`→`<br>`, but **does not handle `\r`**. A `\r` rides verbatim into the assembled single-line EVENT, and the receiver's terminal does carriage-return overwrite.

Three layers all leak `\r`:

| # | Site | Problem |
|---|------|---------|
| 1 | `cmd_send`/`cmd_ring` (cli.rs:1901, 2004) | read stdin **no trim** — Windows `echo` pipes `\r\n`. (`cmd_notify` trims; send/ring forgot.) |
| 2 | `parse_frame` (emit.rs:30,34) | `.trim_end_matches('\n')` — strips `\n`, **leaves trailing `\r`** |
| 3 | `event_body_escape` (envelope.rs:21) | neutralizes `\n`, **not `\r`** → raw CR reaches terminal |

Layer 3 is the real defect: the envelope is line-framed, so any raw `\r` breaks it. `\n` is handled (`<br>`); `\r` was missed. Trimming send input (layers 1) fixes *this* repro but **not** a multi-line Windows message (`line1\r\nline2` → `line1\r<br>line2`, interior `\r` still corrupts). Codec is the durable seam.

Constraint check — envelope.rs is "copied verbatim from sister per ADR-0001, do not clean-room." Need to confirm whether CRLF handling is already a known hazard before I touch the guarded codec.


Bash

cd C:\Users\decid\Documents\projects\spt-core; rg -n -i 'carriage|crlf|\\\\r|\bCR\b|line.ending' docs/KNOWN-HAZARDS.md docs/adr/ traceable-reqs.toml 2>$null | Select-Object -First 40

Search hazards/ADR/reqs for CRLF prior art


Error: Exit code 127
/usr/bin/bash: line 4: cd: C:UsersdecidDocumentsprojectsspt-core: No such file or directory
/usr/bin/bash: line 4: $null: ambiguous redirect
/usr/bin/bash: line 4: Select-Object: command not found


2004	    let body = read_stdin();
2005	    let owlery = perch::owlery_dir();
2006
2007	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
2008	        RingOutcome::Replied(reply) => {
2009	            print!("{}", reply);
2010	            let _ = std::io::stdout().flush();
2011	            0
2012	        }
2013	        RingOutcome::TimedOut => {
2014	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
2015	            0
2016	        }
2017	        RingOutcome::Queued => {
2018	            ok_line(format!("QUEUED:{}", target));
2019	            0
2020	        }
2021	        RingOutcome::Delivered => {
2022	            ok_line(format!(
2023	                "SENT:{} (reply will arrive on your listener)",

    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            ok_line(format!("QUEUED:{}", target));
            0
        }
        RingOutcome::Delivered => {
            ok_line(format!(
                "SENT:{} (reply will arrive on your listener)",


I used a bash heredoc by mistake. Retry with Grep tool.


No matches found


No prior art — new, untracked bug. Per project traceability rules, needs a `REQ-*` entry before the fix.

**Phase 3 — ranked hypotheses (all falsifiable):**

1. **[CONFIRMED] Codec doesn't neutralize `\r`.** `event_body_escape` maps `\n`→`<br>` but passes `\r` raw into the line-framed envelope → terminal CR-overwrite. *Predict: add `\r`→linebreak normalization, malformed render disappears.* — traced in source, exact char-overwrite math matches.
2. **[CONTRIBUTING] send/ring don't trim stdin.** Windows `echo` adds `\r\n`; `cmd_notify` trims, `cmd_send`/`cmd_ring` don't. *Predict: trimming fixes THIS repro but not interior CRLF in multi-line msgs.*
3. **Ruled out:** receiver/WAN transport corruption — the codec produces the bad bytes pre-transport; round-trip parser is fine.

Layer 1 is the durable fix (covers multi-line Windows messages too); layer 2 is hygiene parity with `notify`.

One real decision — fix touches the ADR-0001 "copy-verbatim" codec, so scope matters:


AskUserQuestion

Question: Fix scope for the CR-corruption bug?

Choices:
Codec: normalize CR→linebreak in event_body_escape (durable, covers multiline). Plus trim stdin in cmd_send/cmd_ring for parity with notify. New REQ-HAZARD + regression tests, traceable-tagged.
Just neutralize \r in event_body_escape + parse_frame. Minimal blast radius, leaves send/ring trailing-blank asymmetry vs notify.
Trim stdin in cmd_send/cmd_ring only. Fixes this repro but interior CRLF in multi-line Windows messages still corrupts. Not recommended.


Your questions have been answered: "Fix scope for the CR-corruption bug?"="both. instead of addressing it yourself, send a handoff message to doyle and ask if doyle can roll the fix into the pending v0.3.1 update". You can now continue with these answers in mind.

Question: Fix scope for the CR-corruption bug?

Choices:
Codec: normalize CR→linebreak in event_body_escape (durable, covers multiline). Plus trim stdin in cmd_send/cmd_ring for parity with notify. New REQ-HAZARD + regression tests, traceable-tagged.
Just neutralize \r in event_body_escape + parse_frame. Minimal blast radius, leaves send/ring trailing-blank asymmetry vs notify.
Trim stdin in cmd_send/cmd_ring only. Fixes this repro but interior CRLF in multi-line Windows messages still corrupts. Not recommended.

both. instead of addressing it yourself, send a handoff message to doyle and ask if doyle can roll the fix into the pending v0.3.1 update


Both — handed off to doyle for v0.3.1, not self-fixed. Composing handoff with full diagnosis + exact seams.


Bash

$OWL send doyle <<'EOF'
HANDOFF — bug diagnosis for pending v0.3.1. From todlando (HFENDULEAM).

SYMPTOM: cross-node `spt send` from Windows renders malformed EVENT on receiver. Repro: `echo "hey..." | spt send gerald --from windows` → receiver shows `</EVENT>ype="msg" from="windows">hey...` — the `</EVENT>` overwrote `<EVENT t` (both exactly 8 chars). Classic carriage-return overwrite: a raw `\r` sits before `</EVENT>` in the single-line envelope; receiver terminal does CR→col0 overwrite.

ROOT CAUSE (traced through source, 3 leak layers):
1. crates/spt-proto/src/envelope.rs:21 `event_body_escape` — maps `\n`→`<br>` but does NOT neutralize `\r`. THE durable defect: envelope is line-framed, so any raw `\r` corrupts it. This is the one that must be fixed (covers interior CRLF in multi-line Windows messages too).
2. crates/spt-msg/src/emit.rs:30,34 `parse_frame` — `.trim_end_matches('\n')` strips `\n`, leaves trailing `\r`.
3. crates/spt/src/cli.rs:1901 (cmd_send) + 2004 (cmd_ring) — read stdin with NO trim; Windows `echo` pipes `\r\n`. Note cmd_notify (cli.rs:1572) already does `.trim()` — send/ring just forgot. Asymmetry.

USER DECISION: fix scope = BOTH (codec normalization + send/ring stdin trim). User asked: can you roll this into v0.3.1?

PROPOSED FIX:
- envelope.rs event_body_escape: before the `\n`→`<br>` replace, add `.replace("\r\n","\n").replace('\r',"\n")`. CRLF/lone-CR → linebreak → `<br>`. Wire-compatible (decoder `<br>`→`\n` unchanged), amp-last invariant untouched, existing round-trip test (LF-only input) still passes. NOT a clean-room divergence from ADR-0001 — `\r` was never line-representable; we only normalize unrepresentable input.
- cli.rs cmd_send (1901) + cmd_ring (2004): `let body = read_stdin();` → trim like cmd_notify does (`body.trim()`), for parity + strip trailing blank.

TRACEABILITY: new + untracked (no CRLF/carriage prior art in KNOWN-HAZARDS / ADRs / traceable-reqs.toml). Needs a new REQ first — suggest REQ-HAZARD-ENVELOPE-CR-LINESAFE (codec must neutralize CR so a line-framed envelope can't be terminal-corrupted). Add to traceable-reqs.toml, then `[impl->...]` on the escape fn + `[unit->...]` on a regression test (body with `\r`/`\r\n` → rendered EVENT line contains no raw `\r`; round-trips). `traceable-reqs check` must pass before ship.

Can you take this into v0.3.1, or want me to land it on dev-freeform and you cherry-pick? Your call.
EOF

Send bug handoff to doyle


➜ SENT:doyle


Handoff sent to doyle (`SENT:doyle` — local perch). Asked doyle to roll the codec + send/ring fix into v0.3.1, with full diagnosis, exact seams, proposed patch, and the new `REQ-HAZARD-ENVELOPE-CR-LINESAFE` traceability requirement.

**Summary for you:**
- **Bug:** Windows `\r\n` survives into the line-framed EVENT envelope. `event_body_escape` neutralizes `\n` but not `\r` → receiver terminal CR-overwrites `<EVENT t` with `</EVENT>`.
- **Fix (both, per your call):** codec normalizes CR→linebreak (durable, covers multiline) + `cmd_send`/`cmd_ring` trim stdin (parity with `cmd_notify`).
- **Owner:** doyle, targeting v0.3.1. Waiting on doyle's reply re: land-vs-cherry-pick.

Idle, listening for doyle's response.


if doyle asks you to sign the release build, you have the necessary var in your ENV. just run `cargo run -p xtask -- release-publish --tag v<version_number> --key-id rel-primary-2026 --version <monotonic_version>`


1	- [CI self-host migration](ci-selfhost-migration.md) — DONE: runners live on kitsubito + hfenduleam (gravity retired 2026-06-07); twohost jobs gated by [twohost] commit tag; don't re-plan
2	- [which process is the listener](spt-daemon-is-live-infra.md) — owl.exe (plugin) = live listener, hands off; target\debug\spt.exe daemon = dev/test artifact, killable
3	- [self-clear skill API-blocked](self-clear-skill-api-blocked.md) — never invoke /self-clear; usage-policy classifier refuses the turn; continue in-context / user-manual /clear instead
4	- [Adapter glue-model boundary](adapter-glue-model-boundary.md) — adapters = manifest + binary only; real adapters in own repos; no adapter-aware source in spt-core
5	- [M6 shipped v0.1.0](m6-shipped-v010.md) — v0.1.0 public 2026-06-05; release procedure = RELEASE-RUNBOOK.md; releases-repo/ is repo-face truth; don't re-plan M6
6	- [Gravity deploy procedure](gravity-deploy-procedure.md) — ssh david@100.100.62.43; build in ~/spt-core-deploy NOT runner workspace; setsid daemon; ~1 min first-sync after restart
7	- [Linux SPT_HOME no-bug](linux-spt-home-no-bug.md) — unix spawn_detached inherits env (no env_clear), forwards SPT_HOME; only Win CreateEnvironmentBlock rebuilds/drops it (f99dc25). Default-home daemon = launched w/o the var, not a propagation bug; don't reopen
8	- [kitsubito Linux rig](kitsubito-linux-rig.md) — ssh reavus@kitsubito (key auth); cargo at ~/.cargo/bin; deploy checkout origin=local runner (NOT GitHub) so unpushed commits transfer via git bundle; live spt-daemon user unit, --user ops need no sudo; build-in-scratch leaves ~/.local/bin/spt untouched
9	- [Use $OWL send for messaging](owl-send-not-legacy-spt-send.md) — `$OWL send` works (legacy = where live perches run); `spt send` is the NEW spt-core binary but no perches on it yet → bounces NO_PERCH; don't read `$?` after piping send through tail (use PIPESTATUS)
10	- [daemon service-detection gotcha](daemon-service-detection-gotcha.md) — service detection reading GLOBAL OS state is blind on a single-home non-elevated dev box; gate Linux service routing on SPT_HOME-unset, Windows run-refuse on would-vanish (not is_elevated); push→CI BEFORE tagging a release (v0.3.0 tagged early, CI caught 2 regressions, retagged)
11	- [boot-race self-heal](boot-race-self-heal.md) — v0.3.1: daemon self-heals a net-less boot start (NetHost retry+attach to OnceLock broker); systemd-USER network-online ordering does NOT reliably prevent the race under linger (field-proven kitsubito) — the self-heal is the real guarantee, unit ordering is belt


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


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


if doyle asks you to sign the release build, you have the necessary var in your ENV. just run `cargo run -p xtask -- release-publish --tag v<version_number> --key-id rel-primary-2026 --version <monoto…


Release publish process and signing procedure


Noted. On doyle's signing request: run

```
cargo run -p xtask -- release-publish --tag v<version> --key-id rel-primary-2026 --version <monotonic_version>
```

`rel-primary-2026` key + signing var already in ENV. Will pull exact `<version>` / `<monotonic_version>` from doyle's request before firing (release-publish = outward, one-shot — want the right numbers).

Idle, listening.


<task-notification>
<task-id>bplj3amol</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/5" id="327c4cc0" type="msg" from="doyle">v0.3.2 ready to sign — fix is live + confirmed good + CHANGELOG up to date.

WHAT: REQ-UPD-8, the v0.3.1 cross-OS brick fix.
- `spt update fetch` now stages the signed multi-platform update-set (update-set.json + every platform artifact), never a platform-blind single.
- `apply_staged` refuses any single w</EVENT-PART>
<EVENT-PART seq="2/5" id="327c4cc0">hose platform isn't confirmed == this node (the guard that alone stops the Linux-ELF-as-spt.exe brick).
- additive product_version metadata → friendly apply msg.
- `xtask release-publish` now ALSO builds+signs+uploads update-set.json over both platforms (it did NOT before — that path only existed in debug_rollout).

LIVE: on ma</EVENT-PART>
<EVENT-PART seq="3/5" id="327c4cc0">in, head cabdc61 (commits a65b508 fix + cabdc61 release-prep). Workspace bumped 0.3.1 → 0.3.2.
CONFIRMED: CI green on BOTH runners (kitsubito Linux + hfenduleam Windows) + traceability. traceable-reqs 0 findings. (One socket-bind flake on the first kitsubito run; clean on rerun.)
CHANGELOG: ## [0.3.2] section written (user-facing fix +</EVENT-PART>
<EVENT-PART seq="4/5" id="327c4cc0"> friendly-msg note) — becomes the GitHub Release body verbatim.

YOURS (release ceremony):
1. tag v0.3.2 on main + push → release.yml builds the draft on both runners.
2. release-publish --version 6 (the monotonic counter; v0.3.1 was 5). The set is stamped product_version=&quot;0.3.2&quot; from the tag automatically.

FLA</EVENT-PART>
<EVENT-PART seq="5/5" id="327c4cc0">G: the one real-world test that CI can't run — Linux `spt update fetch` → Windows peer pull → valid PE applied — needs the SIGNED published release to exist. That's the post-publish verify; worth running before re-rolling the fleet. Until v0.3.2 is published, the HARD RULE holds: don't `spt update fetch` on v0.3.1.</EVENT-PART></event>
</task-notification>


Doyle's message reassembled. v0.3.2 ready (REQ-UPD-8 cross-OS update-set brick fix — note: **separate** from my CR-corruption handoff, which doyle didn't mention). Doyle hands me the release ceremony:
1. tag `v0.3.2` on main (head `cabdc61`), push → `release.yml` draft-builds both runners.
2. `release-publish --version 6` (monotonic; v0.3.1 was 5).

CI already green both runners at `cabdc61`. This is the pre-authorized signing path.

Prep before any irreversible step: verify main state + reply to doyle (and flag my CR fix is unaddressed here).


PowerShell

git fetch origin --tags --quiet; git log -1 --format='%H %s' origin/main; "---tags---"; git tag --list 'v0.3.*'

Verify origin/main head and existing release tags


cabdc614fc0416e3a57de0a21b229db485089320 release: prep v0.3.2 (version bump + CHANGELOG)
---tags---
v0.3.0
v0.3.1


Bash

$OWL send --reply-to doyle <<'EOF'
Got it — running v0.3.2 release ceremony now. Verifying origin/main @ cabdc61, tagging v0.3.2, push → release.yml draft both runners, then release-publish --version 6 (key rel-primary-2026, var in my ENV).

One flag: this v0.3.2 is REQ-UPD-8 (update-set brick). My CR-corruption handoff (REQ-HAZARD-ENVELOPE-CR-LINESAFE — Windows \r mangling the EVENT envelope) isn't mentioned here. Is it folded into v0.3.2, or riding a later release? Just confirming it didn't get lost — no blocker on this publish either way.
EOF

Reply to doyle: starting ceremony + CR-fix status


Error: Exit code 2
error: the following required arguments were not provided:
  <TARGET>

Usage: owl.exe send --reply-to <REPLY_TO> <TARGET> [FROM]

For more information, try '--help'.


main verified: `origin/main` @ `cabdc614…` = doyle's `cabdc61`. Tags v0.3.0, v0.3.1 present; **v0.3.2 not yet**. Monotonic: v0.3.1=5 → v0.3.2=6. Confirmed.

Reply syntax differs in this binary — resend with plain `send doyle` (worked before):


$OWL send doyle <<'EOF'
Got it — running v0.3.2 release ceremony now. origin/main verified @ cabdc614 (your cabdc61), v0.3.2 tag not yet present, monotonic 6 (v0.3.1=5). Tagging v0.3.2, push → release.yml draft both runners, then release-publish --tag v0.3.2 --version 6 (key rel-primary-2026).

One flag: this v0.3.2 is REQ-UPD-8 (update-set brick). My CR-corruption handoff (REQ-HAZARD-ENVELOPE-CR-LINESAFE — Windows \r mangling the EVENT envelope) isn't mentioned here. Folded into v0.3.2, or riding a later release? Confirming it didn't get lost — no blocker on this publish either way.
EOF

Reply to doyle: ceremony starting + CR-fix status


main verified, doyle notified. Now the irreversible steps — tag `v0.3.2` on `cabdc614` and push.


PowerShell

git tag -a v0.3.2 cabdc614fc0416e3a57de0a21b229db485089320 -m "release: v0.3.2"; git push origin v0.3.2; "---verify---"; git ls-remote --tags origin v0.3.2

Create annotated v0.3.2 tag on main and push


To https://github.com/SaberMage/spt-core.git
 * [new tag]         v0.3.2 -> v0.3.2
---verify---
4057733bb560645c73b14d6a4c7a14b24ff8f42c	refs/tags/v0.3.2


Tag `v0.3.2` pushed (remote `4057733…`). `release.yml` now draft-building both runners. Must wait for that draft before `release-publish`. Find the run.


gh run list --workflow=release.yml --limit 3 --json databaseId,headBranch,status,conclusion,event,createdAt,displayTitle 2>$null

List recent release.yml workflow runs


<owl_messages>
<owl_message from="doyle" priority="high">CR fix already shipped in v0.3.1 — nothing lost, no v0.3.2 action.

Detail: the v0.3.1 tag points AT c5e3b27 (your CR-linesafe commit, REQ-HAZARD-ENVELOPE-CR-LINESAFE). v0.3.1 is published (isDraft=false, counter 5). c5e3b27 is an ancestor of v0.3.2 main (cabdc61), so it's baked in transitively too.

So v0.3.2 = REQ-UPD-8 only (update-set brick). The CR fix is NOT a v0.3.2 changelog item — it's correctly under ## [0.3.1] ("Messages sent from Windows no longer arrive garbled"). Clean to proceed: tag v0.3.2 → release-publish --version 6.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


release: prep v0.3.2 (version bump + CHANGELOG)

fix(proto): CR-linesafe EVENT codec + send/ring stdin trim (REQ-HAZAR…


Doyle confirms — **my CR fix already shipped in v0.3.1** (commit c5e3b27, REQ-HAZARD-ENVELOPE-CR-LINESAFE, under ## [0.3.1] changelog). Verified by the run list: v0.3.1 title = "fix(proto): CR-linesafe EVENT codec + send/ring stdin trim". Handoff was actioned, nothing lost. v0.3.2 = REQ-UPD-8 only. Clean to proceed.

Acknowledge doyle + watch the queued release run (27198856591) to completion before publish.


Bash

$OWL send doyle <<'EOF'
Ack — CR fix confirmed shipped in v0.3.1 (c5e3b27, ancestor of cabdc61, under ## [0.3.1]). Nothing lost. v0.3.2 = REQ-UPD-8 only. Proceeding: tag pushed (run 27198856591 queued), watching release.yml on both runners, then release-publish --tag v0.3.2 --version 6.
EOF

Ack doyle CR-fix confirmation


➜ SENT:doyle


gh run watch 27198856591 --exit-status; echo "EXIT=$?"

Watch release.yml run to completion


Command running in background with ID: bfp3693r4. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\bfp3693r4.output. You will be notified when it completes. To check interim output, use Read on that file path.


Acked doyle. Tag `v0.3.2` pushed; `release.yml` watch running in background (task `bfp3693r4`) — draft-builds Linux (kitsubito) + Windows (hfenduleam).

**State:**
- ✅ CR-corruption handoff → already shipped in **v0.3.1** (c5e3b27). Done.
- ✅ v0.3.2 = REQ-UPD-8 update-set brick fix. Tag pushed.
- ⏳ Waiting on `release.yml` draft build both runners.
- ⬜ Next: `cargo run -p xtask -- release-publish --tag v0.3.2 --key-id rel-primary-2026 --version 6`

Idle until the run finishes, then publish.


<task-notification>
<task-id>bfp3693r4</task-id>
<tool-use-id>toolu_01Cz1U5CzJaByQG4d4pNE8J6</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\bfp3693r4.output</output-file>
<status>completed</status>
<summary>Background command "Watch release.yml run to completion" completed (exit code 0)</summary>
</task-notification>


Run completed exit 0 (success). Confirm conclusion + draft release exists, then publish.


PowerShell

gh run view 27198856591 --json status,conclusion,displayTitle | ConvertFrom-Json | Format-List; "---release---"; gh release view v0.3.2 --json tagName,isDraft,name 2>$null

Confirm run conclusion and draft release state


Error: Exit code 1
conclusion   : success
displayTitle : release: prep v0.3.2 (version bump + CHANGELOG)
status       : completed

---release---


Run succeeded. Release-by-tag not found — check if a draft exists under a different name.


gh release list --limit 5 --json tagName,isDraft,name,createdAt 2>$null; "---raw---"; gh release list --limit 5 2>&1

List releases incl drafts


[]
---raw---


No GitHub releases listed — this project publishes via `xtask release-publish` (releases-repo is repo-face truth, per ops memory), not GH release drafts. v0.3.1 (published, counter 5) likewise absent here. So `release.yml` staged the built artifacts; `release-publish` does the sign+publish. Run succeeded, artifacts ready.

Executing the pre-authorized publish — builds+signs+uploads update-set over both platforms, so backgrounding to stay reachable:


cargo run -p xtask -- release-publish --tag v0.3.2 --key-id rel-primary-2026 --version 6; "PUBLISH_EXIT=$LASTEXITCODE"

Sign + publish v0.3.2 release-set (both platforms)


Command running in background with ID: b2t5l2rvl. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\b2t5l2rvl.output. You will be notified when it completes. To check interim output, use Read on that file path.


`release-publish` running (task `b2t5l2rvl`) — signing with `rel-primary-2026`, building+uploading update-set.json over Linux + Windows, monotonic version 6.

**State:**
- ✅ Tag `v0.3.2` pushed, `release.yml` green both runners.
- ⏳ Signing + publishing update-set (in progress).
- ⬜ Post-publish: doyle's FLAG — the one test CI can't do: Linux `spt update fetch` → Windows peer pull → valid PE applied. Needs the signed release live. Worth running before re-rolling the fleet. **Hard rule until v0.3.2 published: no `spt update fetch` on v0.3.1.**

Idle until publish completes, then verify + report. Will surface doyle's post-publish verify step.


<task-notification>
<task-id>b2t5l2rvl</task-id>
<tool-use-id>toolu_01SoQj1ufAkWaFdzEPnmYi7Z</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\84c95489-7e9c-42fe-9b5b-e1fb164fd365\tasks\b2t5l2rvl.output</output-file>
<status>completed</status>
<summary>Background command "Sign + publish v0.3.2 release-set (both platforms)" completed (exit code 0)</summary>
</task-notification>


Publish exited 0. Read output to confirm signed artifacts + update-set landed.