1	//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
2	//! (spt-core harness-contract v0.13.0+).
3	//!
4	//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
5	//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
6	//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
7	//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
8	//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
9	//!
10	//! Input protocol (stdin, one JSON object per line):
11	//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
12	//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
13	//!   {"type":"input"}                            — content-free ping when the operator types (no output)
14	//!
15	//! Output protocol (stdout, one JSON object per line):
16	//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
17	//!   {"delay_ms":50}      — inter-command pause
18	//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits the PTY line)
19	//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
20	//!
21	//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
22	//! may have in the input box, then terminates the inject sequence:
23	//!   1. ctrl+s              stash any existing draft
24	//!   2. delay 50ms
25	//!   3. <envelope>\r        type the envelope, the carriage return submits the line
26	//!   4. {"commit":true}     terminate the inject sequence
27	//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
28	//! second ctrl+s would be redundant (it would re-stash, not restore).
29	//!
30	//! TWO DISTINCT SIGNALS — do not conflate them (doyle-confirmed from broker.rs, 2026-06-20/22):
31	//!  - The trailing `\r` in step 3 is the HARNESS-level submit: spt-core enqueues a `{"text":…}`
32	//!    command VERBATIM to the PTY (no control-char stripping; broker.rs:1066, :1016-1017), so the `\r`
33	//!    byte submits the line exactly like a separate `{"key":"enter"}` (`key_to_bytes("enter")->b"\r"`).
34	//!    That verbatim application is also WHY `commands_for_event` neutralizes the envelope's INTERNAL
35	//!    CR/LF — an un-neutralized `\n`/`\r` would reach the PTY and submit early.
36	//!  - The `{"commit":true}` in step 4 is the PROTOCOL-level terminator: `run_inject_worker`
37	//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
38	//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
39	//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
40	//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
41	//!    and releases the InjectFloor race-free. `\r` submits the line; `{commit}` ends the sequence.
42	//!
43	//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
44	//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY
45	//! omitted `{commit}` from the vocabulary and its degenerate example — a public-surface defect this
46	//! adapter's blind-build caught, logged F-016. The contract is now CORRECTED + live:
47	//! harness-contract/manifest.html documents `{commit}` as the mandatory terminator + the 5s
48	//! commit-deadline + inject-floor release + raw-inject fallback, since v0.13.0.)
49	//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
50	//! inbound message never eats an in-progress draft.
51	//!
52	//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
53	//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
54	//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]
55
56	use serde_json::{json, Value};
57	use std::io::{self, BufRead, Write};
58
59	/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
60	const DELAY_MS: u64 = 50;
61
62	/// Build the keystroke-command sequence for one inbound EVENT envelope.
63	///
64	/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
65	/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
66	/// newline can never submit early or split the injection — the single trailing `\r` we append is the
67	/// ONLY submit. (Necessary because spt-core applies `{"text"}` verbatim — broker.rs:1066/:1016-1017,
68	/// doyle 2026-06-20 — so any internal CR/LF would otherwise reach the PTY.) The closing `{"commit"}`
69	/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
70	/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]
71	fn commands_for_event(envelope: &str) -> Vec<Value> {
72	    let sanitized: String = envelope
73	        .chars()
74	        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
75	        .collect();
76	    vec![
77	        json!({ "key": "ctrl+s" }),                  // 1. stash any existing draft
78	        json!({ "delay_ms": DELAY_MS }),             // 2. let the stash settle
79	        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits the line; CC auto-restores draft
80	        json!({ "commit": true }),                   // 4. terminate the inject sequence (release the InjectFloor)
81	    ]
82	}
83
84	/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
85	/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
86	fn commands_for_line(v: &Value) -> Vec<Value> {
87	    match v.get("type").and_then(Value::as_str) {
88	        Some("event") => match v.get("envelope").and_then(Value::as_str) {
89	            Some(env) => commands_for_event(env),
90	            None => Vec::new(), // event without an envelope: nothing to deliver
91	        },
92	        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
93	        _ => Vec::new(),
94	    }
95	}
96
97	fn main() {
98	    let stdin = io::stdin();
99	    let stdout = io::stdout();
100	    let mut out = stdout.lock();
101
102	    for line in stdin.lock().lines() {
103	        let line = match line {
104	            Ok(l) => l,
105	            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
106	        };
107	        let trimmed = line.trim();
108	        if trimmed.is_empty() {
109	            continue;
110	        }
111	        let v: Value = match serde_json::from_str(trimmed) {
112	            Ok(v) => v,
113	            Err(e) => {
114	                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
115	                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
116	                continue;
117	            }
118	        };
119	        for cmd in commands_for_line(&v) {
120	            // One compact JSON object per line; flush per command so spt-core applies promptly.
121	            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
122	                return; // stdout closed: endpoint gone.
123	            }
124	        }
125	    }
126	}
127
128	#[cfg(test)]
129	mod tests {
130	    use super::*;
131
132	    fn keys(cmds: &[Value]) -> Vec<String> {
133	        cmds.iter()
134	            .map(|c| {
135	                if let Some(k) = c.get("key").and_then(Value::as_str) {
136	                    format!("key:{k}")
137	                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
138	                    format!("delay:{d}")
139	                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
140	                    format!("text:{t}")
141	                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
142	                    "commit".into()
143	                } else {
144	                    "?".into()
145	                }
146	            })
147	            .collect()
148	    }
149
150	    #[test]
151	    fn event_emits_the_stash_submit_commit_choreography() {
152	        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
153	        assert_eq!(
154	            keys(&cmds),
155	            vec![
156	                "key:ctrl+s".to_string(),
157	                "delay:50".to_string(),
158	                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
159	                "commit".to_string(),
160	            ]
161	        );
162	    }
163
164	    #[test]
165	    fn sequence_terminates_with_a_mandatory_commit() {
166	        // broker.rs run_inject_worker ends a sequence ONLY on {commit:true}; without it the broker
167	        // FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery. The terminator MUST be last.
168	        let cmds = commands_for_event("m");
169	        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
170	        // Exactly one commit, and it is the final command (nothing emits after the terminator).
171	        let commits = cmds.iter().filter(|c| c.get("commit").is_some()).count();
172	        assert_eq!(commits, 1, "exactly one terminating commit");
173	    }
174
175	    #[test]
176	    fn no_trailing_restore_keystroke() {
177	        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
178	        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
179	        let cmds = commands_for_event("m");
180	        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
181	        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
182	    }
183
184	    #[test]
185	    fn submit_is_a_trailing_carriage_return_on_the_text() {
186	        let cmds = commands_for_event("payload");
187	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
188	        assert!(text.ends_with('\r'), "text must end with the submit \\r");
189	        assert_eq!(text, "payload\r");
190	        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
191	        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
192	    }
193
194	    #[test]
195	    fn stash_precedes_the_submit() {
196	        let cmds = commands_for_event("x");
197	        // ctrl+s (stash) is FIRST; the text submit is cmds[2]; the {commit} terminator is last.
198	        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
199	        assert!(cmds[2].get("text").is_some());
200	        assert_eq!(cmds.len(), 4);
201	    }
202
203	    #[test]
204	    fn raw_newlines_in_envelope_are_neutralized() {
205	        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
206	        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
207	        let cmds = commands_for_event("a\nb\r\nc");
208	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
209	        assert_eq!(text, "a b  c\r");
210	        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
211	        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
212	    }
213
214	    #[test]
215	    fn event_dispatch_via_line() {
216	        let v: Value = serde_json::from_str(
217	            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
218	        )
219	        .unwrap();
220	        let cmds = commands_for_line(&v);
221	        assert_eq!(cmds.len(), 4);
222	        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
223	    }
224
225	    #[test]
226	    fn init_and_input_and_unknown_types_emit_nothing() {
227	        for line in [
228	            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
229	            r#"{"type":"input"}"#,
230	            r#"{"type":"future-thing","x":1}"#,
231	            r#"{"no_type":true}"#,
232	        ] {
233	            let v: Value = serde_json::from_str(line).unwrap();
234	            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
235	        }
236	    }
237
238	    #[test]
239	    fn event_without_envelope_emits_nothing() {
240	        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
241	        assert!(commands_for_line(&v).is_empty());
242	    }
243
244	    #[test]
245	    fn unknown_fields_are_ignored_forward_compat() {
246	        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
247	        let v: Value = serde_json::from_str(
248	            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
249	        )
250	        .unwrap();
251	        assert_eq!(commands_for_line(&v).len(), 4);
252	    }
253
254	    #[test]
255	    fn output_commands_are_single_field_objects() {
256	        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text | commit).
257	        for c in commands_for_event("m") {
258	            let obj = c.as_object().unwrap();
259	            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
260	            let k = obj.keys().next().unwrap().as_str();
261	            assert!(matches!(k, "key" | "delay_ms" | "text" | "commit"), "unexpected field {k}");
262	        }
263	    }
264	}
265

//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).
//!
//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
//!
//! Input protocol (stdin, one JSON object per line):
//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
//!   {"type":"input"}                            — content-free ping when the operator types (no output)
//!
//! Output protocol (stdout, one JSON object per line):
//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits the PTY line)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms
//!   3. <envelope>\r        type the envelope, the carriage return submits the line
//!   4. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! TWO DISTINCT SIGNALS — do not conflate them (doyle-confirmed from broker.rs, 2026-06-20/22):
//!  - The trailing `\r` in step 3 is the HARNESS-level submit: spt-core enqueues a `{"text":…}`
//!    command VERBATIM to the PTY (no control-char stripping; broker.rs:1066, :1016-1017), so the `\r`
//!    byte submits the line exactly like a separate `{"key":"enter"}` (`key_to_bytes("enter")->b"\r"`).
//!    That verbatim application is also WHY `commands_for_event` neutralizes the envelope's INTERNAL
//!    CR/LF — an un-neutralized `\n`/`\r` would reach the PTY and submit early.
//!  - The `{"commit":true}` in step 4 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. `\r` submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY
//! omitted `{commit}` from the vocabulary and its degenerate example — a public-surface defect this
//! adapter's blind-build caught, logged F-016. The contract is now CORRECTED + live:
//! harness-contract/manifest.html documents `{commit}` as the mandatory terminator + the 5s
//! commit-deadline + inject-floor release + raw-inject fallback, since v0.13.0.)
//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
//! inbound message never eats an in-progress draft.
//!
//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]

use serde_json::{json, Value};
use std::io::{self, BufRead, Write};

/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
const DELAY_MS: u64 = 50;

/// Build the keystroke-command sequence for one inbound EVENT envelope.
///
/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never submit early or split the injection — the single trailing `\r` we append is the
/// ONLY submit. (Necessary because spt-core applies `{"text"}` verbatim — broker.rs:1066/:1016-1017,
/// doyle 2026-06-20 — so any internal CR/LF would otherwise reach the PTY.) The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]
fn commands_for_event(envelope: &str) -> Vec<Value> {
    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),                  // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }),             // 2. let the stash settle
        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits the line; CC auto-restores draft
        json!({ "commit": true }),                   // 4. terminate the inject sequence (release the InjectFloor)
    ]
}

/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
fn commands_for_line(v: &Value) -> Vec<Value> {
    match v.get("type").and_then(Value::as_str) {
        Some("event") => match v.get("envelope").and_then(Value::as_str) {
            Some(env) => commands_for_event(env),
            None => Vec::new(), // event without an envelope: nothing to deliver
        },
        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
        _ => Vec::new(),
    }
}

fn main() {
    let stdin = io::stdin();
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let v: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
                continue;
            }
        };
        for cmd in commands_for_line(&v) {
            // One compact JSON object per line; flush per command so spt-core applies promptly.
            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
                return; // stdout closed: endpoint gone.
            }
        }
    }
}

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

    fn keys(cmds: &[Value]) -> Vec<String> {
        cmds.iter()
            .map(|c| {
                if let Some(k) = c.get("key").and_then(Value::as_str) {
                    format!("key:{k}")
                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
                    format!("delay:{d}")
                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
                    format!("text:{t}")
                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
                    "commit".into()
                } else {
                    "?".into()
                }
            })
            .collect()
    }

    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
                "commit".to_string(),
            ]
        );
    }

    #[test]
    fn sequence_terminates_with_a_mandatory_commit() {
        // broker.rs run_inject_worker ends a sequence ONLY on {commit:true}; without it the broker
        // FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery. The terminator MUST be last.
        let cmds = commands_for_event("m");
        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
        // Exactly one commit, and it is the final command (nothing emits after the terminator).
        let commits = cmds.iter().filter(|c| c.get("commit").is_some()).count();
        assert_eq!(commits, 1, "exactly one terminating commit");
    }

    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_trailing_carriage_return_on_the_text() {
        let cmds = commands_for_event("payload");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert!(text.ends_with('\r'), "text must end with the submit \\r");
        assert_eq!(text, "payload\r");
        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text submit is cmds[2]; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 4);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c\r");
        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn event_dispatch_via_line() {
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
        )
        .unwrap();
        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 4);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
    }

    #[test]
    fn init_and_input_and_unknown_types_emit_nothing() {
        for line in [
            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
            r#"{"type":"input"}"#,
            r#"{"type":"future-thing","x":1}"#,
            r#"{"no_type":true}"#,
        ] {
            let v: Value = serde_json::from_str(line).unwrap();
            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
        }
    }

    #[test]
    fn event_without_envelope_emits_nothing() {
        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
        assert!(commands_for_line(&v).is_empty());
    }

    #[test]
    fn unknown_fields_are_ignored_forward_compat() {
        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 4);
    }

    #[test]
    fn output_commands_are_single_field_objects() {
        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text | commit).
        for c in commands_for_event("m") {
            let obj = c.as_object().unwrap();
            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
            let k = obj.keys().next().unwrap().as_str();
            assert!(matches!(k, "key" | "delay_ms" | "text" | "commit"), "unexpected field {k}");
        }
    }
}


just discovered: `\r` is not sufficient for a claude code message submission. so i need you to revamp the translation binary output to remove `\r` at the end of the message, and insert an enter keypre…


Fix message submission in translation binary


Found it. `main.rs:79` carries submit in text as trailing `\r`. Need: drop `\r`, add `{"key":"enter"}`. Let me check related references too.


1	#!/bin/sh
2	# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
3	# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
4	# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
5	# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
6	# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
7	# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
8	#
9	# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
10	# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
11	# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
12	# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
13	# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
14	# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
15	# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
16	#
17	# GREEN against the {commit}-terminated binary: emits the 4-command choreography ctrl+s · 50ms ·
18	# {text:"<envelope>\r"} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes". The no-{commit}
19	# form FAULTs (the F-016 defect the proof's no-commit gate catches).
20	set -u
21	ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
22	MANIFEST="$ROOT/adapter/claude-spt.toml"
23	RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
24	EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'
25
26	command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
27
28	# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
29	if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
30	  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
31	  exit 0
32	fi
33
34	BIN="$RELDIR/cc-spt-idle-translate"
35	[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
36	[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }
37
38	# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
39	# pins the bare-file gh_release manifest — no extracted install, no registry touch.
40	out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
41	rc=0
42	case "$out" in
43	  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
44	  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
45	esac
46	# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
47	case "$out" in
48	  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
49	  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
50	esac
51	# And the verbatim \r submit rides inside the text command (not a separate enter key).
52	case "$out" in
53	  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;
54	  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;
55	esac
56
57	[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }
58

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 4-command choreography ctrl+s · 50ms ·
# {text:"<envelope>\r"} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes". The no-{commit}
# form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# And the verbatim \r submit rides inside the text command (not a separate enter key).
case "$out" in
  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;
  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }


191-    BEGIN { ORS="" }
192-    {
193:      gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r");
194-      if (NR > 1) printf "%s", "\\n";
195-      printf "%s", $0;


_common.sh = JSON escaping only, not submit mechanism. Out of scope. Check traceable-reqs.toml ref:


102-id = "REQ-UPS-INJECTION"
103-title = "UserPromptSubmit hook detects \sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
104:required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.
105-
106-[[requirements]]
--
249-[[requirements]]
250-id = "REQ-DIST-IDLE-TRANSLATE"
251:[Omitted long matching line]
252:[Omitted long matching line]


249	[[requirements]]
250	id = "REQ-DIST-IDLE-TRANSLATE"
251	title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope>\\r (the CR submits the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the \r submits the line, {commit} releases the InjectFloor — two distinct signals, doyle-confirmed 2026-06-22). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."
252	required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→4-step choreography [stash · 50ms · <envelope>\r submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so only the trailing submit \r fires, forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, text+\r submit-not-enter-key, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the trailing \r in the text command. GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the trailing \r submits (== {key:enter}, key_to_bytes("enter")->b"\r") AND the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY) — doyle is republishing the manifest to state text-is-verbatim authoritatively (a doc gap, the contract example showed {text}{key:enter} but never stated verbatim).
253

[[requirements]]
id = "REQ-DIST-IDLE-TRANSLATE"
title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope>\\r (the CR submits the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the \r submits the line, {commit} releases the InjectFloor — two distinct signals, doyle-confirmed 2026-06-22). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."
required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→4-step choreography [stash · 50ms · <envelope>\r submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so only the trailing submit \r fires, forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, text+\r submit-not-enter-key, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the trailing \r in the text command. GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the trailing \r submits (== {key:enter}, key_to_bytes("enter")->b"\r") AND the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY) — doyle is republishing the manifest to state text-is-verbatim authoritatively (a doc gap, the contract example showed {text}{key:enter} but never stated verbatim).


Now make the changes. Edit `main.rs` — function first:


Edit

    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),                  // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }),             // 2. let the stash settle
        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits the line; CC auto-restores draft
        json!({ "commit": true }),                   // 4. terminate the inject sequence (release the InjectFloor)
    ]

    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
        json!({ "text": sanitized }),    // 3. type the envelope — NO trailing CR (a \r byte does not submit CC)
        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key
        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)
        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)
    ]


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

    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),                  // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }),             // 2. let the stash settle
        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits the line; CC auto-restores draft
        json!({ "commit": true }),                   // 4. terminate the inject sequence (release the InjectFloor)
    ]

    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
        json!({ "text": sanitized }),    // 3. type the envelope — NO trailing CR (a \r byte does not submit CC)
        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key
        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)
        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)
    ]

-        json!({ "key": "ctrl+s" }),                  // 1. stash any existing draft

-        json!({ "delay_ms": DELAY_MS }),             // 2. let the stash settle

-        json!({ "text": format!("{sanitized}\r") }), // 3. envelope + CR submits the line; CC auto-restores draft

-        json!({ "commit": true }),                   // 4. terminate the inject sequence (release the InjectFloor)

+        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft

+        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle

+        json!({ "text": sanitized }),    // 3. type the envelope — NO trailing CR (a \r byte does not submit CC)

+        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key

+        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)

+        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)


Now the module doc comments:


//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits the PTY line)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms
//!   3. <envelope>\r        type the envelope, the carriage return submits the line
//!   4. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! TWO DISTINCT SIGNALS — do not conflate them (doyle-confirmed from broker.rs, 2026-06-20/22):
//!  - The trailing `\r` in step 3 is the HARNESS-level submit: spt-core enqueues a `{"text":…}`
//!    command VERBATIM to the PTY (no control-char stripping; broker.rs:1066, :1016-1017), so the `\r`
//!    byte submits the line exactly like a separate `{"key":"enter"}` (`key_to_bytes("enter")->b"\r"`).
//!    That verbatim application is also WHY `commands_for_event` neutralizes the envelope's INTERNAL
//!    CR/LF — an un-neutralized `\n`/`\r` would reach the PTY and submit early.
//!  - The `{"commit":true}` in step 4 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. `\r` submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit.

//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms          let the stash settle
//!   3. <envelope>          type the envelope (no trailing CR)
//!   4. delay 50ms          let the text land in CC's input box before the submit key
//!   5. {"key":"enter"}     submit the line
//!   6. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the
//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM
//! to the PTY (broker.rs:1066, :1016-1017) and a `\r` byte == `{"key":"enter"}`
//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does
//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is
//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES
//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would
//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.
//!
//! TWO DISTINCT SIGNALS — do not conflate them:
//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).
//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit.


//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits the PTY line)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms
//!   3. <envelope>\r        type the envelope, the carriage return submits the line
//!   4. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! TWO DISTINCT SIGNALS — do not conflate them (doyle-confirmed from broker.rs, 2026-06-20/22):
//!  - The trailing `\r` in step 3 is the HARNESS-level submit: spt-core enqueues a `{"text":…}`
//!    command VERBATIM to the PTY (no control-char stripping; broker.rs:1066, :1016-1017), so the `\r`
//!    byte submits the line exactly like a separate `{"key":"enter"}` (`key_to_bytes("enter")->b"\r"`).
//!    That verbatim application is also WHY `commands_for_event` neutralizes the envelope's INTERNAL
//!    CR/LF — an un-neutralized `\n`/`\r` would reach the PTY and submit early.
//!  - The `{"commit":true}` in step 4 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. `\r` submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit.

//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms          let the stash settle
//!   3. <envelope>          type the envelope (no trailing CR)
//!   4. delay 50ms          let the text land in CC's input box before the submit key
//!   5. {"key":"enter"}     submit the line
//!   6. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the
//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM
//! to the PTY (broker.rs:1066, :1016-1017) and a `\r` byte == `{"key":"enter"}`
//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does
//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is
//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES
//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would
//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.
//!
//! TWO DISTINCT SIGNALS — do not conflate them:
//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).
//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit.

//! Output protocol (stdout, one JSON object per line):

+//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)

-//!   {"text":"<payload>"} — text injection (the envelope; a trailing \r submits the PTY line)

+//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)

//!   1. ctrl+s              stash any existing draft

-//!   3. <envelope>\r        type the envelope, the carriage return submits the line

-//!   4. {"commit":true}     terminate the inject sequence

+//!   2. delay 50ms          let the stash settle

+//!   3. <envelope>          type the envelope (no trailing CR)

+//!   4. delay 50ms          let the text land in CC's input box before the submit key

+//!   5. {"key":"enter"}     submit the line

+//!   6. {"commit":true}     terminate the inject sequence

-//! TWO DISTINCT SIGNALS — do not conflate them (doyle-confirmed from broker.rs, 2026-06-20/22):

-//!  - The trailing `\r` in step 3 is the HARNESS-level submit: spt-core enqueues a `{"text":…}`

-//!    command VERBATIM to the PTY (no control-char stripping; broker.rs:1066, :1016-1017), so the `\r`

-//!    byte submits the line exactly like a separate `{"key":"enter"}` (`key_to_bytes("enter")->b"\r"`).

-//!    That verbatim application is also WHY `commands_for_event` neutralizes the envelope's INTERNAL

-//!    CR/LF — an un-neutralized `\n`/`\r` would reach the PTY and submit early.

-//!  - The `{"commit":true}` in step 4 is the PROTOCOL-level terminator: `run_inject_worker`

+//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the

+//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM

+//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does

+//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is

+//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES

+//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would

+//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.

+//! TWO DISTINCT SIGNALS — do not conflate them:

+//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).

+//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`

-//!    and releases the InjectFloor race-free. `\r` submits the line; `{commit}` ends the sequence.

+//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.

//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY


Now the `commands_for_event` doc comment:


/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never submit early or split the injection — the single trailing `\r` we append is the
/// ONLY submit. (Necessary because spt-core applies `{"text"}` verbatim — broker.rs:1066/:1016-1017,
/// doyle 2026-06-20 — so any internal CR/LF would otherwise reach the PTY.) The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]

/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`
/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise
/// reach the PTY.) The submit is a DISCRETE `{"key":"enter"}` AFTER the text — a trailing `\r` byte in
/// the text does NOT submit a Claude Code message (corrected 2026-06-23). The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]


/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never submit early or split the injection — the single trailing `\r` we append is the
/// ONLY submit. (Necessary because spt-core applies `{"text"}` verbatim — broker.rs:1066/:1016-1017,
/// doyle 2026-06-20 — so any internal CR/LF would otherwise reach the PTY.) The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]

/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`
/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise
/// reach the PTY.) The submit is a DISCRETE `{"key":"enter"}` AFTER the text — a trailing `\r` byte in
/// the text does NOT submit a Claude Code message (corrected 2026-06-23). The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]

-/// newline can never submit early or split the injection — the single trailing `\r` we append is the

-/// ONLY submit. (Necessary because spt-core applies `{"text"}` verbatim — broker.rs:1066/:1016-1017,

-/// doyle 2026-06-20 — so any internal CR/LF would otherwise reach the PTY.) The closing `{"commit"}`

+/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`

+/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise

+/// reach the PTY.) The submit is a DISCRETE `{"key":"enter"}` AFTER the text — a trailing `\r` byte in

+/// the text does NOT submit a Claude Code message (corrected 2026-06-23). The closing `{"commit"}`

fn commands_for_event(envelope: &str) -> Vec<Value> {


Now update tests:


    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
                "commit".to_string(),
            ]
        );
    }

    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>".to_string(),
                "delay:50".to_string(),
                "key:enter".to_string(),
                "commit".to_string(),
            ]
        );
    }


    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),
                "commit".to_string(),
            ]
        );
    }

    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>".to_string(),
                "delay:50".to_string(),
                "key:enter".to_string(),
                "commit".to_string(),
            ]
        );
    }

-                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>\r".to_string(),

+                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>".to_string(),

+                "delay:50".to_string(),

+                "key:enter".to_string(),


    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_trailing_carriage_return_on_the_text() {
        let cmds = commands_for_event("payload");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert!(text.ends_with('\r'), "text must end with the submit \\r");
        assert_eq!(text, "payload\r");
        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text submit is cmds[2]; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 4);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c\r");
        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {
        let cmds = commands_for_event("payload");
        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "payload");
        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");
        // The submit is a discrete enter keypress, after the text.
        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
        assert_eq!(enters, 1, "exactly one enter key (the submit)");
        // ...and it comes AFTER the text command.
        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();
        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();
        assert!(enter_idx > text_idx, "the enter submit must follow the text");
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 6);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.
        // Embedded newlines collapse to spaces; NO \r survives in the text (the submit is enter).
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c");
        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }


    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_trailing_carriage_return_on_the_text() {
        let cmds = commands_for_event("payload");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert!(text.ends_with('\r'), "text must end with the submit \\r");
        assert_eq!(text, "payload\r");
        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).
        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text submit is cmds[2]; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 4);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not produce an early submit or split the injection — only the trailing
        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c\r");
        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {
        let cmds = commands_for_event("payload");
        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "payload");
        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");
        // The submit is a discrete enter keypress, after the text.
        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
        assert_eq!(enters, 1, "exactly one enter key (the submit)");
        // ...and it comes AFTER the text command.
        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();
        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();
        assert!(enter_idx > text_idx, "the enter submit must follow the text");
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 6);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.
        // Embedded newlines collapse to spaces; NO \r survives in the text (the submit is enter).
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c");
        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

-    fn submit_is_a_trailing_carriage_return_on_the_text() {

+    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {

+        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.

-        assert!(text.ends_with('\r'), "text must end with the submit \\r");

-        // The submit is carried IN the text, not a separate enter key (operator spec: text+\r).

-        assert!(cmds.iter().all(|c| c.get("key").and_then(Value::as_str) != Some("enter")));

+        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");

+        // The submit is a discrete enter keypress, after the text.

+        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();

+        assert_eq!(enters, 1, "exactly one enter key (the submit)");

+        // ...and it comes AFTER the text command.

+        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();

+        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();

+        assert!(enter_idx > text_idx, "the enter submit must follow the text");

-        // ctrl+s (stash) is FIRST; the text submit is cmds[2]; the {commit} terminator is last.

+        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.

-        // A stray CR/LF must not produce an early submit or split the injection — only the trailing

-        // \r submits. Embedded newlines collapse to spaces; the single submit \r is the LAST char.

+        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.

+        // Embedded newlines collapse to spaces; NO \r survives in the text (the submit is enter).

-        assert_eq!(text.matches('\r').count(), 1, "exactly one (submit) CR");

+        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");


        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 4);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));

        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 6);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));


        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 4);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));

        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 6);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));


        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 4);
    }

        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 6);
    }


        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 4);
    }

        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 6);
    }

-        assert_eq!(commands_for_line(&v).len(), 4);

+        assert_eq!(commands_for_line(&v).len(), 6);


Now the proof-int script:


# GREEN against the {commit}-terminated binary: emits the 4-command choreography ctrl+s · 50ms ·
# {text:"<envelope>\r"} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes". The no-{commit}
# form FAULTs (the F-016 defect the proof's no-commit gate catches).

# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\idle-translate\translate-proof-int.sh has been updated successfully. (file state is current in your context — no need to Read it back)

# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 4-command choreography ctrl+s · 50ms ·
# {text:"<envelope>\r"} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes". The no-{commit}
# form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# And the verbatim \r submit rides inside the text command (not a separate enter key).
case "$out" in
  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;
  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }

-# GREEN against the {commit}-terminated binary: emits the 4-command choreography ctrl+s · 50ms ·

-# {text:"<envelope>\r"} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes". The no-{commit}

-# form FAULTs (the F-016 defect the proof's no-commit gate catches).

+# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·

+# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".

+# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,

+# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).


# And the verbatim \r submit rides inside the text command (not a separate enter key).
case "$out" in
  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;
  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;
esac

# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).
case "$out" in
  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;
  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;
esac
# ...and the text command must NOT carry a trailing \r anymore.
case "$out" in
  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  text command carries no trailing \\r" ;;
esac


# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).
case "$out" in
  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;
  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;
esac
# ...and the text command must NOT carry a trailing \r anymore.
case "$out" in
  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;
  *) echo "ok  text command carries no trailing \\r" ;;
esac

#!/bin/sh
# Integration proof: `spt adapter translate-proof` spawns + feeds the claude-spt
# [message-idle-translation-binary] (cc-spt-idle-translate) EXACTLY as the daemon does at idle
# delivery (init line then the --event envelope), reads back the emitted keystroke-command stream,
# and gates it. This is the EMIT half of REQ-DIST-IDLE-TRANSLATE — the cross-process author-time proof
# (ADR-0022, the EMIT-half mirror of digest-proof). It does NOT exercise the daemon's atomic PTY APPLY
# or controller buffering — that half stays the real-claude bringup int (deferred). [int->REQ-DIST-IDLE-TRANSLATE]
#
# Uses the v0.13.2 `--dir`/`--manifest` override (F-011 closed, W5) to proof the DEV binary straight
# from its build dir against the bare-file manifest: --dir resolves the binary (before PATH) exactly
# as the daemon does, --manifest pins the bare-file manifest. NO registry mutation — the prior form
# staged a disposable install dir and `adapter add`/`adapter remove claude-spt`, which soft-removed
# the REAL registered claude-spt on cleanup. Read-only now, so NO SPTC_ACCEPTANCE gate. Needs
# spt >= 0.13.2 (the --dir/--manifest options) + a built binary. Idempotent.
# Run: sh ci/idle-translate/translate-proof-int.sh   (exit 0 = pass).
#
# GREEN against the {commit}-terminated binary: emits the 6-command choreography ctrl+s · 50ms ·
# {text:"<envelope>"} · 50ms · {key:enter} · {commit:true}, exit 0 / TRANSLATE_PROOF_OK / "commit: yes".
# The submit is a discrete {key:enter} AFTER the text (a trailing \r byte does NOT submit a CC message,
# corrected 2026-06-23). The no-{commit} form FAULTs (the F-016 defect the proof's no-commit gate catches).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
RELDIR="$ROOT/tools/cc-spt-idle-translate/target/release"
EVENT='<EVENT type="msg" from="ci">translate-proof int probe</EVENT>'

command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }

# --dir/--manifest landed in spt v0.13.2 — capability-detect rather than version-parse.
if ! spt adapter translate-proof --help 2>&1 | grep -q -- '--dir'; then
  echo "SKIP: spt 'adapter translate-proof' has no --dir/--manifest (needs v0.13.2). Binary itself: cargo tests green."
  exit 0
fi

BIN="$RELDIR/cc-spt-idle-translate"
[ -x "$BIN" ] || BIN="$RELDIR/cc-spt-idle-translate.exe"
[ -x "$BIN" ] || { echo "SKIP: binary not built (run sh ci/idle-translate/build.sh)"; exit 0; }

# Proof the dev binary in-place: --dir resolves the binary (before PATH) like the daemon, --manifest
# pins the bare-file gh_release manifest — no extracted install, no registry touch.
out=$(spt adapter translate-proof claude-spt --event "$EVENT" --manifest "$MANIFEST" --dir "$RELDIR" 2>&1)
rc=0
case "$out" in
  *TRANSLATE_PROOF_OK*) echo "ok  translate-proof: TRANSLATE_PROOF_OK" ;;
  *) echo "FAIL: translate-proof did not pass:"; printf '%s\n' "$out"; exit 1 ;;
esac
# The {commit} terminator is the F-016 fix — assert the proof saw it (no-commit would FAULT live).
case "$out" in
  *"commit: yes"*) echo "ok  emitted the mandatory {commit} terminator (commit: yes)" ;;
  *) echo "FAIL: proof reports no commit terminator (the F-016 fault condition):"; printf '%s\n' "$out"; rc=1 ;;
esac
# And the verbatim \r submit rides inside the text command (not a separate enter key).
case "$out" in
  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;
  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;
esac

[ "$rc" -eq 0 ] && { echo "TRANSLATE-PROOF-INT OK"; exit 0; } || { echo "TRANSLATE-PROOF-INT FAIL"; exit 1; }

-# And the verbatim \r submit rides inside the text command (not a separate enter key).

+# The submit is a discrete enter keypress (NOT a trailing \r in the text — a \r byte does not submit CC).

-  *'\r"'*) echo "ok  text command carries the trailing \\r submit" ;;

-  *) echo "FAIL: no trailing \\r in the text command:"; printf '%s\n' "$out"; rc=1 ;;

+  *enter*) echo "ok  emitted the discrete {key:enter} submit" ;;

+  *) echo "FAIL: no {key:enter} submit in the command stream:"; printf '%s\n' "$out"; rc=1 ;;

+# ...and the text command must NOT carry a trailing \r anymore.

+  *'\r"'*) echo "FAIL: text still carries a trailing \\r (should be a discrete enter):"; printf '%s\n' "$out"; rc=1 ;;

+  *) echo "ok  text command carries no trailing \\r" ;;


Now traceable-reqs.toml. Update title:


title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope>\\r (the CR submits the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the \r submits the line, {commit} releases the InjectFloor — two distinct signals, doyle-confirmed 2026-06-22). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."


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

-title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope>\\r (the CR submits the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the \r submits the line, {commit} releases the InjectFloor — two distinct signals, doyle-confirmed 2026-06-22). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

+title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→4-step choreography [stash · 50ms · <envelope>\r submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so only the trailing submit \r fires, forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, text+\r submit-not-enter-key, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the trailing \r in the text command. GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the trailing \r submits (== {key:enter}, key_to_bytes("enter")->b"\r") AND the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY) — doyle is republishing the manifest to state text-is-verbatim authoritatively (a doc gap, the contract example showed {text}{key:enter} but never stated verbatim).


Now the comment line. Several edits:


required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→4-step choreography [stash · 50ms · <envelope>\r submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so only the trailing submit \r fires, forward-compat unknown fields ignored)

required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→6-step choreography [stash · 50ms · <envelope> text · 50ms · {key:enter} submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so the injection can't split — only the discrete enter submits (a \r byte does NOT submit a CC message, corrected 2026-06-23), forward-compat unknown fields ignored)