1	// Claude session management: init, resume, final invocations
2
3	use std::io::Write;
4	use std::process::{Command, Stdio};
5
6	use super::WrapperState;
7	use crate::common::owlery;
8	use crate::common::wrapper_state::{self as wrapper_state_helper, WrapperHandoffState};
9	use crate::live::context::git_commit_context;
10	use crate::live::wrapper::lifecycle;
11
12	/// Psyche template embedded at compile time
13	const PSYCHE_TEMPLATE: &str = include_str!("../../../psyche.md");
14
15	/// Build the --agents JSON string with substituted psyche.md template.
16	fn build_agents_json(self_id: &str, psyche_id: &str, period: u64) -> String {
17	    // Sentinel: period == 0 means "no scheduled pulse". Replace the FULL segment
18	    // `{{period}} seconds` so the identity-block line reads coherently in both
19	    // modes. psyche.md line 6 is exactly `Pulse period: {{period}} seconds`.
20	    let period_phrase: String = if period == 0 {
21	        "disabled (no scheduled pulses; event-driven wake only)".to_string()
22	    } else {
23	        format!("{} seconds", period)
24	    };
25	    let prompt = PSYCHE_TEMPLATE
26	        .replace("{{self_id}}", self_id)
27	        .replace("{{psyche_id}}", psyche_id)
28	        .replace("{{period}} seconds", &period_phrase);
29
30	    serde_json::json!({
31	        "owl-psyche": {
32	            "description": "Psyche agent for owl live system",
33	            "prompt": prompt,
34	            "tools": ["Read", "Write", "Edit"]
35	        }
36	    })
37	    .to_string()
38	}
39
40	impl WrapperState {
41	    /// Initialize a new Claude session. Returns the session UUID on success, exits on failure.
42	    pub fn init_session(&mut self) {
43	        let agents_json = build_agents_json(&self.self_id, &self.psyche_id, self.period);
44
45	        let pulse_phrase = if self.period == 0 {
46	            "no scheduled pulses".to_string()
47	        } else {
48	            format!("Pulse period: {}s", self.period)
49	        };
50	        let init_msg = format!(
51	            "You are now active. Begin monitoring for {}. {}. Generation: {}.",
52	            self.self_id, pulse_phrase, self.gen
53	        );
54
55	        let mut init_cmd = Command::new(&self.cli_binary);
56	        init_cmd.current_dir(owlery::psyche_dir());
57	        init_cmd.env_remove("OWL_SESSION_ID");
58	        init_cmd.args([
59	            "-p",
60	            "--agents",
61	            &agents_json,
62	            "--agent",
63	            "owl-psyche",
64	            "--name",
65	            &self.session_name,
66	            "--model",
67	            "sonnet",
68	            "--fallback-model",
69	            "opus",
70	            "--effort",
71	            "medium",
72	            "--dangerously-skip-permissions",
73	            "--disable-slash-commands",
74	            "--tools",
75	            "Read,Edit,Write",
76	            "--output-format",
77	            "json",
78	        ]);
79	        init_cmd.stdin(Stdio::piped());
80	        init_cmd.stdout(Stdio::piped());
81	        init_cmd.stderr(Stdio::null());
82	        #[cfg(windows)]
83	        {
84	            use std::os::windows::process::CommandExt;
85	            const CREATE_NO_WINDOW: u32 = 0x08000000;
86	            init_cmd.creation_flags(CREATE_NO_WINDOW);
87	        }
88
89	        // Phase 18.8 Pitfall 5: soft-stopped perches preserve owlery/<id>/ contents
90	        // including .last-commune-epoch. A fresh live session in the same perch
91	        // would otherwise see a stale cursor and filter out legitimate new turns.
92	        // Clear the marker here so the first echo-commune of this new session
93	        // uses the 64KB-tail fallback (marker-missing path).
94	        //
95	        // WR-08 (acknowledged): there is a narrow window during PULSE_TRIGGER
96	        // recovery where an in-flight echo-commune from the PREVIOUS session
97	        // (spawned before init_session ran) can re-write the cursor AFTER this
98	        // remove, overwriting the intentional clear. Low probability in practice
99	        // because echo-commune fires are wrapper-gated by .more-done sentinel.
100	        // The fallback on missing/stale cursor is "full 64KB tail" (safe
101	        // degradation), so accepting the risk per D-13/D-19 semantics. A
102	        // session-token file would be the correctness fix if this proves to
103	        // matter in practice.
104	        let _ = std::fs::remove_file(crate::common::owlery::last_commune_epoch_path(&self.self_id));
105
106	        self.log("spawning claude init...");
107	        let session_uuid = match init_cmd.spawn() {
108	            Ok(mut child) => {
109	                self.log(&format!("claude init spawned pid={}", child.id()));
110	                if let Some(mut stdin) = child.stdin.take() {
111	                    // IN-02: log stdin write failures instead of dropping silently.
112	                    if let Err(e) = stdin.write_all(init_msg.as_bytes()) {
113	                        self.log(&format!("[STDIN] init_session write failed: {}", e));
114	                    }
115	                }
116	                self.log("stdin written, waiting for output...");
117
118	                let output = child.wait_with_output().unwrap_or_else(|e| {
119	                    self.log(&format!("wait_with_output FAILED: {}", e));
120	                    std::process::exit(1);
121	                });
122
123	                let exit_code = output.status.code().unwrap_or(-1);
124	                let stdout = String::from_utf8_lossy(&output.stdout);
125	                let stderr = String::from_utf8_lossy(&output.stderr);
126	                self.log(&format!(
127	                    "claude init exited code={} stdout_len={} stderr_len={}",
128	                    exit_code,
129	                    stdout.len(),
130	                    stderr.len()
131	                ));
132	                self.log(&format!(
133	                    "stdout preview: {}",
134	                    stdout.chars().take(500).collect::<String>()
135	                ));
136	                if exit_code != 0 {
137	                    self.log(&format!(
138	                        "stderr: {}",
139	                        stderr.chars().take(500).collect::<String>()
140	                    ));
141	                }
142	                extract_session_uuid(&stdout)
143	            }
144	            Err(e) => {
145	                self.log(&format!("claude init spawn FAILED: {}", e));
146	                std::process::exit(1);
147	            }
148	        };
149
150	        match session_uuid {
151	            Some(uuid) => {
152	                self.log(&format!("init success, session_uuid={}", uuid));
153	                self.session_uuid = uuid;
154	                self.log("auto-commit: git_commit_context after init_session");
155	                git_commit_context(&owlery::psyche_dir(), &self.self_id);
156
157	                // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): if the LLM
158	                // wrote `agents/{self_id}/live_context.md` containing a
159	                // `<project-context>` slice, route it synchronously to
160	                // `projects/<resolved>/<self_id>.md` via the shared helper.
161	                // No-op if the project slice is missing (G2 self-heal lives
162	                // in Plan 25.3-06). Re-entry handled by RefCell guard.
163	                //
164	                // 25.3-06 D-G2-01: pass `Some(&init_msg)` so the helper can
165	                // evaluate G2 trigger condition (c) (prompt contains
166	                // `CURRENT_PROJECT_CONTEXT`). For init_session specifically,
167	                // `init_msg` is identity-only (no CURRENT_PROJECT_CONTEXT
168	                // block) — condition (c) will be false here so G2 NEVER
169	                // fires on init. Pass `self` as the production
170	                // ResumeRespawnDispatcher impl (delegates to
171	                // resume_session_with_exit).
172	                let _ = self.route_live_context_md_if_changed(Some(&init_msg), self);
173
174	                // Phase 24 Plan 04 — publish fresh session_uuid to
175	                // wrapper-state.json so external sessions-log emit sites
176	                // (start.rs / signoff.rs / echo_commune.rs) can read the
177	                // current UUID without contention against the
178	                // wrapper's destructive handoff load path. Soft-fail
179	                // per D-02 — the in-memory `self.session_uuid` is the
180	                // canonical source for in-wrapper emits; the file is a
181	                // best-effort mirror for external consumers.
182	                let state = WrapperHandoffState {
183	                    session_uuid: self.session_uuid.clone(),
184	                    gen: self.gen,
185	                    last_fresh_launch_epoch:
186	                        lifecycle::instant_to_epoch(self.last_fresh_launch),
187	                    // 260523-648: include pulse_psyche on every re-publish so
188	                    // external readers see the operator's choice.
189	                    pulse_psyche: self.pulse_psyche,
190	                };
191	                // Phase 25.4 D-03: migration-window CLOSED. Wrapper-state
192	                // writers route through `write_wrapper_state_at(self_id,
193	                // psyche_id, state)` — the central perch_path resolver places
194	                // the file at `owlery/<self>/nested/<psyche>/wrapper-state.json`
195	                // (CONTEXT D-04 explicit-parent). The reader
196	                // `wrapper_state_path_resolved` remains nested-first /
197	                // flat-fallback for one deploy cycle's handoff transition
198	                // (removal pending follow-up phase).
199	                if let Err(e) = wrapper_state_helper::write_wrapper_state_at(
200	                    &self.self_id,
201	                    &self.psyche_id,
202	                    &state,
203	                )
204	                {
205	                    self.log(&format!(
206	                        "wrapper_state publish after init_session failed: {} (soft-fail)",
207	                        e
208	                    ));
209	                }
210	            }
211	            None => {
212	                self.log("init FAILED: no session UUID captured");
213	                std::process::exit(1);
214	            }
215	        }
216	    }
217
218	    /// Resume the Claude session with a message (regular poll iteration).
219	    /// Kept for backward compatibility; main loop uses resume_session_checked().
220	    ///
221	    /// IN-05: previously an 87-line near-duplicate of resume_session_checked
222	    /// that had drifted (missing the git_commit_context auto-commit added to
223	    /// the checked variant). Reduced to a delegating one-liner — the two
224	    /// functions can no longer diverge, and the `#[allow(dead_code)]` symbol
225	    /// is preserved for any external skill / command-dispatch reference.
226	    #[allow(dead_code)]
227	    pub fn resume_session(&self, msg: &str) {
228	        let _ = self.resume_session_checked(msg);
229	    }
230
231	    /// Phase 30 Plan 02 Task 1 — exit-code-aware sibling of
232	    /// `resume_session_checked`. Same subprocess shape, same logging, same
233	    /// passive-context composition; the ONLY difference is the return type:
234	    /// `(Option<String>, i32)` where the second element is the captured
235	    /// `output.status.code().unwrap_or(-1)`.
236	    ///
237	    /// Exit-code semantics (i32 conventions):
238	    /// - `>= 0`: subprocess exited with this code (0 = success per POSIX).
239	    /// - `-1`: subprocess was terminated by a signal (no code available), OR
240	    ///   `wait_with_output` returned Err, OR `spawn()` failed.
241	    ///
242	    /// Phase 30 D8 requires deletion of `.claude/{id}-{kind}.md` ONLY on
243	    /// `exit_code == 0`; any non-zero (including the `-1` spawn/wait failure
244	    /// sentinel) MUST retain the file for retry on next consume.
245	    ///
246	    /// Canonical regression coverage for the `(Option<String>, i32)` tuple
247	    /// contract is provided by Plan 04's `tests/file_drop_integration.rs`
248	    /// Tests 2-3 (subprocess-driven integration). The 5 existing callers
249	    /// of `resume_session_checked` go through the 1-line wrapper below and
250	    /// see no behavior change.
251	    pub fn resume_session_with_exit(&self, msg: &str) -> (Option<String>, i32) {
252	        let trimmed = msg.trim();
253	        let first_line = trimmed.lines().next().unwrap_or("");
254	        let body_preview: String = trimmed
255	            .lines()
256	            .skip(1)
257	            .flat_map(|l| l.chars().chain(std::iter::once(' ')))
258	            .take(100)
259	            .collect();
260	        self.log(&format!(
261	            "[MSG] from={}: \"{}\"",
262	            first_line.chars().take(40).collect::<String>(),
263	            body_preview.trim()
264	        ));
265
266	        // 25.3-03 cycle-5 HIGH 3 — wrapper LLM-stdin boundary. Route the
267	        // incoming `msg` through `compose_llm_prompt_from_envelope` so that
268	        // if it is an EVENT envelope, the LLM sees a natural-language prompt
269	        // (header + decoded body) instead of `<EVENT>` framing tokens.
270	        // Non-envelope inputs (raw PULSE_TRIGGER, plain text) fall through
271	        // unchanged — the boundary helper is idempotent and defensive.
272	        let prompt = super::compose_llm_prompt_from_envelope(msg);
273
274	        let now = chrono::Local::now()
275	            .format("%Y-%m-%dT%H:%M:%S%:z")
276	            .to_string();
277	        let passive = self.compose_passive_context();
278	        let timestamped = format!("[Current time: {}]\n{}{}", now, prompt, passive);
279
280	        self.log_block(
281	            &format!("[STDIN] {} bytes to resume:", timestamped.len()),
282	            &timestamped,
283	        );
284
285	        let mut resume_cmd = Command::new(&self.cli_binary);
286	        resume_cmd.current_dir(owlery::psyche_dir());
287	        resume_cmd.env_remove("OWL_SESSION_ID");
288	        resume_cmd.args([
289	            "-p",
290	            "--resume",
291	            &self.session_uuid,
292	            "--model",
293	            "sonnet",
294	            "--fallback-model",
295	            "opus",
296	            "--effort",
297	            "medium",
298	            "--dangerously-skip-permissions",
299	            "--disable-slash-commands",
300	            "--tools",
301	            "Read,Edit,Write",
302	        ]);
303	        resume_cmd.stdin(Stdio::piped());
304	        resume_cmd.stdout(Stdio::piped());
305	        resume_cmd.stderr(Stdio::piped());
306	        #[cfg(windows)]
307	        {
308	            use std::os::windows::process::CommandExt;
309	            const CREATE_NO_WINDOW: u32 = 0x08000000;
310	            resume_cmd.creation_flags(CREATE_NO_WINDOW);
311	        }
312
313	        if let Ok(mut child) = resume_cmd.spawn() {
314	            if let Some(mut stdin) = child.stdin.take() {
315	                // IN-02: log stdin write failures.
316	                if let Err(e) = stdin.write_all(timestamped.as_bytes()) {
317	                    self.log(&format!(
318	                        "[STDIN] resume_session_with_exit write failed: {}",
319	                        e
320	                    ));
321	                }
322	            }
323	            match child.wait_with_output() {
324	                Ok(output) => {
325	                    let stdout = String::from_utf8_lossy(&output.stdout);
326	                    let stderr = String::from_utf8_lossy(&output.stderr);
327	                    let exit_code = output.status.code().unwrap_or(-1);
328	                    if !stderr.trim().is_empty() {
329	                        self.log_block("[PSYCHE] resume stderr:", &stderr);
330	                    }
331	                    if !stdout.is_empty() {
332	                        let response_text = extract_claude_response(&stdout);
333	                        self.log_block(
334	                            &format!("[PSYCHE] resume (exit={}):", exit_code),
335	                            &response_text,
336	                        );
337	                        self.log("auto-commit: git_commit_context after resume_session_with_exit");
338	                        git_commit_context(&owlery::psyche_dir(), &self.self_id);
339
340	                        // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): if
341	                        // the LLM wrote `agents/{self_id}/live_context.md`
342	                        // containing a `<project-context>` slice, route it
343	                        // synchronously. When the project slice is missing,
344	                        // Plan 25.3-06's G2 self-heal fires (subject to
345	                        // D-G2-01 conditions a+b+c).
346	                        //
347	                        // 25.3-06 D-G2-01: pass `Some(&timestamped)` — the
348	                        // FULL prompt the LLM saw on its stdin this turn
349	                        // (header + decoded body + passive context). The
350	                        // passive context (via `compose_passive_context`)
351	                        // is what carries the `CURRENT_PROJECT_CONTEXT
352	                        // (name=...):` block when the project is
353	                        // resolvable. Greppable substring satisfies
354	                        // condition (c). `self` is the production
355	                        // ResumeRespawnDispatcher impl (delegates back to
356	                        // resume_session_with_exit — re-entry guarded by
357	                        // live_ctx_route_in_flight).
358	                        let _ = self.route_live_context_md_if_changed(Some(&timestamped), self);
359
360	                        // Phase 24 Plan 04 — sessions.log pulse trigger
361	                        // (D-11). The wrapper IS the writer; pass the
362	                        // in-memory `self.session_uuid` directly per Q6
363	                        // LOCKED disposition — no state-file read needed
364	                        // inside the wrapper. D-18 dedup means 50 pulses
365	                        // for one UUID produce ONE row whose ts advances.
366	                        // Soft-fail per D-02.
367	                        let _ = crate::common::tracked::append_session_entry(
368	                            &self.self_id,
369	                            &self.session_uuid,
370	                            "pulse",
371	                        );
372
373	                        return (Some(response_text), exit_code);
374	                    } else {
375	                        self.log(&format!(
376	                            "[PSYCHE] resume (exit={}): (no output)",
377	                            exit_code
378	                        ));
379	                        return (None, exit_code);
380	                    }
381	                }
382	                Err(e) => {
383	                    self.log(&format!("[PSYCHE] resume wait failed: {}", e));
384	                    return (None, -1);
385	                }
386	            }
387	        }
388	        (None, -1)
389	    }
390
391	    /// Resume the Claude session and return the response text if output was produced.
392	    /// Returns Some(response_text) on success, None on failure or empty output.
393	    ///
394	    /// Phase 30 Plan 02 Task 1: thin wrapper over `resume_session_with_exit`
395	    /// — discards the exit code. Preserves the existing public API and the
396	    /// 5 known caller sites' semantics (compile-time tested by `cargo build`
397	    /// and behaviourally tested by pre-existing wrapper tests).
398	    pub fn resume_session_checked(&self, msg: &str) -> Option<String> {
399	        let (resp, _exit_code) = self.resume_session_with_exit(msg);
400	        resp
401	    }
402
403	    /// Phase 24 Plan 04 — in-wrapper helper that emits one `boot` row to
404	    /// `agents/{self_id}/sessions.log` after a fresh init_session inside the
405	    /// wrapper process (24h-refresh, PULSE_TRIGGER tier-1/tier-2 recovery).
406	    ///
407	    /// Contrast with `start.rs::emit_boot_trigger_after_spawn`: that helper
408	    /// runs OUTSIDE the wrapper process and sources the UUID via
409	    /// `read_wrapper_state`. This helper runs INSIDE the wrapper and uses
410	    /// the in-memory UUID directly per Q6 LOCKED.
411	    ///
412	    /// Seals the prior generation's sessions.log via D-12 BEFORE appending
413	    /// the new boot row. All errors soft-fail per D-02.
414	    pub(crate) fn emit_inwrapper_boot(self_id: &str, session_uuid: &str) {
415	        let prior_gen = crate::live::context::read_status(self_id)
416	            .map(|s| s.generation)
417	            .unwrap_or(1);
418	        let _ = crate::common::tracked::seal_and_rotate_sessions_log(self_id, prior_gen);
419	        let _ = crate::common::tracked::append_session_entry(self_id, session_uuid, "boot");
420	        // Phase 24.1 D-06: bump tracked info.json last_started on the boot
421	        // trigger. Empty names + empty branch — boot does NOT touch project
422	        // fields (D-09 + D-10 reserve those for commune/signoff). Soft-fail
423	        // via `let _`. Pairs with start.rs::emit_boot_trigger_after_spawn —
424	        // both boot emit sites bump per RESEARCH §Pattern 2 lifecycle map.
425	        let _ = crate::common::owlery::bump_tracked_agent_info(
426	            self_id, "boot", &[], "",
427	        );
428	    }
429
430	    /// D-01: 3-tier PULSE_TRIGGER recovery when resume produces empty stdout.
431	    /// Tier 1: Fresh session (new init_session, no --resume)
432	    /// Tier 2: Try `ccs` CLI instead of `claude` -- latch on success
433	    /// Tier 3: Wait for next pulse interval (return without action)
434	    pub fn handle_pulse_trigger_recovery(&mut self, msg: &str) {
435	        // Tier 1: Fresh session (no --resume)
436	        self.log("PULSE_TRIGGER recovery tier 1: trying fresh session");
437	        self.init_session();
438	        self.last_fresh_launch = std::time::Instant::now();
439	        // Phase 24 Plan 04 — Tier 1 init_session is a fresh `claude -p`
440	        // with no --resume (D-11 `boot`). Seal the prior generation first
441	        // (D-12), then append a boot row keyed by the new self.session_uuid.
442	        // Soft-fail per D-02.
443	        Self::emit_inwrapper_boot(&self.self_id, &self.session_uuid);
444	        if self.resume_session_checked(msg).is_some() {
445	            self.log("PULSE_TRIGGER recovery tier 1 succeeded (fresh session)");
446	            return;
447	        }
448
449	        // Tier 2: Try `ccs` CLI
450	        self.log("PULSE_TRIGGER recovery tier 2: trying ccs CLI");
451	        let original_cli = self.cli_binary.clone();
452	        self.cli_binary = "ccs".to_string();
453	        self.init_session();
454	        self.last_fresh_launch = std::time::Instant::now();
455	        // Phase 24 Plan 04 — Tier 2 also init_sessions a fresh claude
456	        // (under the `ccs` CLI). Same boot-trigger emit.
457	        Self::emit_inwrapper_boot(&self.self_id, &self.session_uuid);
458	        if self.resume_session_checked(msg).is_some() {
459	            self.log("PULSE_TRIGGER recovery tier 2 succeeded -- LATCHING ccs as CLI for remainder of wrapper session");
460	            // cli_binary stays as "ccs" -- latched
461	            return;
462	        }
463
464	        // Tier 2 failed -- revert to original CLI
465	        self.cli_binary = original_cli;
466	        // Tier 3: Wait for next pulse interval
467	        self.log("PULSE_TRIGGER recovery tier 3: all tiers failed, waiting for next pulse interval");
468	        // No action -- the loop continues and will try again on next PULSE_TRIGGER
469	    }
470
471	    /// Fire one last Claude session invocation for INIT_SIGNOFF context save, then signal exit.
472	    /// Returns true to indicate the wrapper should break out of the loop.
473	    pub fn final_session(&self, msg: &str) -> bool {
474	        self.log("INIT_SIGNOFF detected, firing final context save");
475	        // 25.3-03 cycle-5 HIGH 3 — apply the same LLM-stdin boundary as
476	        // `resume_session_with_exit`. Inbound INIT_SIGNOFF arrives as an
477	        // EVENT envelope (composed via `compose_init_signoff_payload`); the
478	        // LLM must see the natural-language prompt, not the EVENT framing.
479	        let prompt = super::compose_llm_prompt_from_envelope(msg);
480	        let now = chrono::Local::now()
481	            .format("%Y-%m-%dT%H:%M:%S%:z")
482	            .to_string();
483	        let passive = self.compose_passive_context();
484	        let timestamped = format!("[Current time: {}]\n{}{}", now, prompt, passive);
485
486	        self.log_block(
487	            &format!("[STDIN] {} bytes to final:", timestamped.len()),
488	            &timestamped,
489	        );
490
491	        let mut final_cmd = Command::new(&self.cli_binary);
492	        final_cmd.current_dir(owlery::psyche_dir());
493	        final_cmd.env_remove("OWL_SESSION_ID");
494	        final_cmd.args([
495	            "-p",
496	            "--resume",
497	            &self.session_uuid,
498	            "--model",
499	            "sonnet",
500	            "--fallback-model",
501	            "opus",
502	            "--effort",
503	            "medium",
504	            "--dangerously-skip-permissions",
505	            "--disable-slash-commands",
506	            "--tools",
507	            "Read,Edit,Write",
508	        ]);
509	        final_cmd.stdin(Stdio::piped());
510	        final_cmd.stdout(Stdio::piped());
511	        final_cmd.stderr(Stdio::piped());
512	        #[cfg(windows)]
513	        {
514	            use std::os::windows::process::CommandExt;
515	            const CREATE_NO_WINDOW: u32 = 0x08000000;
516	            final_cmd.creation_flags(CREATE_NO_WINDOW);
517	        }
518	        if let Ok(mut child) = final_cmd.spawn() {
519	            if let Some(mut stdin) = child.stdin.take() {
520	                // IN-02: log stdin write failures.
521	                if let Err(e) = stdin.write_all(timestamped.as_bytes()) {
522	                    self.log(&format!("[STDIN] final_session write failed: {}", e));
523	                }
524	            }
525	            match child.wait_with_output() {
526	                Ok(output) => {
527	                    let stdout = String::from_utf8_lossy(&output.stdout);
528	                    let stderr = String::from_utf8_lossy(&output.stderr);
529	                    let exit_code = output.status.code().unwrap_or(-1);
530	                    if !stdout.is_empty() {
531	                        let response_text = extract_claude_response(&stdout);
532	                        self.log_block(
533	                            &format!("[PSYCHE] final (exit={}):", exit_code),
534	                            &response_text,
535	                        );
536	                    } else {
537	                        self.log(&format!(
538	                            "[PSYCHE] final (exit={}): (no output)",
539	                            exit_code
540	                        ));
541	                    }
542	                    if !stderr.trim().is_empty() {
543	                        self.log_block("[PSYCHE] final stderr:", &stderr);
544	                    }
545	                }
546	                Err(e) => {
547	                    self.log(&format!("[PSYCHE] final wait failed: {}", e));
548	                }
549	            }
550	        }
551	        self.log("auto-commit: git_commit_context after final_session");
552	        git_commit_context(&owlery::psyche_dir(), &self.self_id);
553
554	        // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): final post-LLM-exit
555	        // route. Same helper as init_session + resume_session_with_exit.
556	        //
557	        // 25.3-06 D-G2-01: pass `None` for `last_prompt` — final_session
558	        // does NOT have a follow-on LLM call this iteration (the wrapper
559	        // is exiting). G2 must not fire here even if the project slice is
560	        // missing, since respawning the LLM via the corrective prompt
561	        // contradicts the "finalize and exit" semantic of the final-session
562	        // path. With `last_prompt = None`, condition (c) is unconditionally
563	        // false → helper returns SkippedNoProjectSlice (Plan 25.3-05
564	        // behavior preserved).
565	        let _ = self.route_live_context_md_if_changed(None, self);
566
567	        self.log("Final psyche invocation complete, wrapper exiting");
568	        true
569	    }
570
571	    /// Log a multi-line block: header with timestamp, then indented body lines.
572	    /// Emits `>>>  (empty)` when body is blank so the "header with colon + no body"
573	    /// pattern can never recur silently (debug: psyche-final-note-not-logged).
574	    fn log_block(&self, header: &str, body: &str) {
575	        self.log(header);
576	        if let Ok(mut f) = std::fs::OpenOptions::new()
577	            .create(true)
578	            .append(true)
579	            .open(&self.log_path)
580	        {
581	            if body.trim().is_empty() {
582	                let _ = writeln!(f, ">>>  (empty)");
583	            } else {
584	                for line in body.lines() {
585	                    let _ = writeln!(f, ">>>  {}", line);
586	                }
587	            }
588	        }
589	    }
590	}
591
592	/// Parse [REPLY], [NOTIFY], and [COMMUNE] markers from Psyche's response text.
593	/// Returns a list of (marker_type, content) tuples.
594	pub fn parse_markers(response: &str) -> Vec<(String, String)> {
595	    let mut markers = Vec::new();
596	    for marker_type in &["REPLY", "NOTIFY", "COMMUNE"] {
597	        let open_tag = format!("[{}]", marker_type);
598	        let close_tag = format!("[/{}]", marker_type);
599	        let mut search_from = 0;
600	        while let Some(start) = response[search_from..].find(&open_tag) {
601	            let content_start = search_from + start + open_tag.len();
602	            if let Some(end) = response[content_start..].find(&close_tag) {
603	                let content = response[content_start..content_start + end].trim().to_string();
604	                if !content.is_empty() {
605	                    markers.push((marker_type.to_string(), content));
606	                }
607	                search_from = content_start + end + close_tag.len();
608	            } else {
609	                break;
610	            }
611	        }
612	    }
613	    markers
614	}
615
616	/// Extract the human-readable response text from claude output.
617	///
618	/// Handles three shapes in order:
619	/// 1. `--output-format json`: whole-buffer JSON with top-level `result` string (init path)
620	/// 2. `--output-format stream-json`: JSONL where each line is an event; aggregates
621	///    text from `{type:"assistant", message:{content:[{type:"text", text:...}]}}`
622	///    events and falls back to a terminal `{result:"..."}` line if present
623	/// 3. Plain text (resume/final paths deliberately omit `--output-format` for
624	///    readable log blocks): returns `output.trim()`
625	///
626	/// Any shape that trims to empty returns "" — callers (and `log_block`) must
627	/// surface the empty case explicitly.
628	pub(crate) fn extract_claude_response(output: &str) -> String {
629	    let trimmed = output.trim();
630	    if trimmed.is_empty() {
631	        return String::new();
632	    }
633
634	    // Shape 1: whole-buffer JSON with "result"
635	    if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
636	        if let Some(result) = val.get("result").and_then(|v| v.as_str()) {
637	            return result.to_string();
638	        }
639	    }
640
641	    // Shape 2: stream-json (JSONL) — aggregate assistant text events
642	    let mut parts: Vec<String> = Vec::new();
643	    let mut final_result: Option<String> = None;
644	    let mut any_json_line = false;
645	    for line in trimmed.lines() {
646	        let line = line.trim();
647	        if line.is_empty() {
648	            continue;
649	        }
650	        let val = match serde_json::from_str::<serde_json::Value>(line) {
651	            Ok(v) => v,
652	            Err(_) => continue,
653	        };
654	        any_json_line = true;
655	        if val.get("type").and_then(|t| t.as_str()) == Some("assistant") {
656	            if let Some(content) = val.pointer("/message/content").and_then(|c| c.as_array()) {
657	                for item in content {
658	                    if item.get("type").and_then(|t| t.as_str()) == Some("text") {
659	                        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
660	                            parts.push(text.to_string());
661	                        }
662	                    }
663	                }
664	            }
665	        }
666	        if let Some(result) = val.get("result").and_then(|v| v.as_str()) {
667	            final_result = Some(result.to_string());
668	        }
669	    }
670	    if any_json_line {
671	        if !parts.is_empty() {
672	            return parts.join("\n");
673	        }
674	        if let Some(result) = final_result {
675	            return result;
676	        }
677	    }
678
679	    // Shape 3: plain text
680	    trimmed.to_string()
681	}
682
683	/// Standard UUID string length: 8-4-4-4-12 hex chars + 4 dashes = 36.
684	/// IN-03: named constant replaces the magic number `36` across this module.
685	const UUID_LEN: usize = 36;
686
687	/// JSON key prefix for session_id extraction.
688	/// IN-03: named constant replaces the magic offset `14` which was
689	/// `"\"session_id\":\"".len()`.
690	const SESSION_ID_KEY: &str = "\"session_id\":\"";
691
692	/// Extract a session UUID from claude JSON output.
693	/// Tries "session_id":"<uuid>" first, then falls back to UUID regex pattern.
694	fn extract_session_uuid(output: &str) -> Option<String> {
695	    // Try JSON key extraction: "session_id":"<uuid>"
696	    if let Some(start) = output.find(SESSION_ID_KEY) {
697	        let rest = &output[start + SESSION_ID_KEY.len()..];
698	        if let Some(end) = rest.find('"') {
699	            let uuid = &rest[..end];
700	            if is_uuid_like(uuid) {
701	                return Some(uuid.to_string());
702	            }
703	        }
704	    }
705
706	    // Fallback: find any UUID-like pattern (8-4-4-4-12 hex chars)
707	    // WR-05: guard against slicing at non-char-boundaries. If `output` contains
708	    // multi-byte UTF-8 (non-ASCII error text, smart quotes from locale-dependent
709	    // error prose, or mixed stderr capture), slicing `&output[i..i+UUID_LEN]` at a
710	    // non-char-boundary panics before is_uuid_like runs. Skip those indices.
711	    let mut i = 0;
712	    let bytes = output.as_bytes();
713	    while i + UUID_LEN <= bytes.len() {
714	        if output.is_char_boundary(i) && output.is_char_boundary(i + UUID_LEN) {
715	            let candidate = &output[i..i + UUID_LEN];
716	            if is_uuid_like(candidate) {
717	                return Some(candidate.to_string());
718	            }
719	        }
720	        i += 1;
721	    }
722
723	    None
724	}
725
726	/// Check if a string matches UUID format: 8-4-4-4-12 hex digits
727	fn is_uuid_like(s: &str) -> bool {
728	    if s.len() != UUID_LEN {
729	        return false;
730	    }
731	    let parts: Vec<&str> = s.split('-').collect();
732	    if parts.len() != 5 {
733	        return false;
734	    }
735	    let expected_lens = [8, 4, 4, 4, 12];
736	    for (part, &expected) in parts.iter().zip(&expected_lens) {
737	        if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
738	            return false;
739	        }
740	    }
741	    true
742	}
743
744	#[cfg(test)]
745	mod tests {
746	    use super::*;
747	    use std::sync::Mutex;
748
749	    /// Env-mutating tests serialize on this lock — same pattern as the
750	    /// other Phase 24 test modules.
751	    static ENV_LOCK: Mutex<()> = Mutex::new(());
752
753	    struct SptHomeSnapshot(Option<String>);
754	    impl SptHomeSnapshot {
755	        fn capture() -> Self { Self(std::env::var("SPT_HOME").ok()) }
756	    }
757	    impl Drop for SptHomeSnapshot {
758	        fn drop(&mut self) {
759	            match &self.0 {
760	                Some(v) => std::env::set_var("SPT_HOME", v),
761	                None => std::env::remove_var("SPT_HOME"),
762	            }
763	        }
764	    }
765
766	    fn git_available() -> bool {
767	        std::process::Command::new("git")
768	            .arg("--version")
769	            .stdout(std::process::Stdio::null())
770	            .stderr(std::process::Stdio::null())
771	            .status()
772	            .map(|s| s.success())
773	            .unwrap_or(false)
774	    }
775
776	    /// Phase 24 Plan 04 — `emit_inwrapper_boot` is the in-wrapper sibling of
777	    /// `start.rs::emit_boot_trigger_after_spawn`. It seals the prior gen's
778	    /// sessions.log AND appends a single `boot` row keyed by the in-memory
779	    /// `session_uuid` (no state-file read inside the wrapper per Q6 LOCKED).
780	    ///
781	    /// First-ever invocation (no sessions.log yet): seal is a no-op (Task 1
782	    /// noop_on_first_boot test) and append creates the file with one row.
783	    /// Second invocation with a NEW uuid: seal commits the prior content
784	    /// and the file is truncated, then the new boot row is appended fresh
785	    /// (sessions.log has ONE row only, with the new UUID).
786	    #[test]
787	    fn emit_inwrapper_boot_first_call_appends_single_boot_row() {
788	        if !git_available() {
789	            eprintln!("skipping: git not on PATH");
790	            return;
791	        }
792	        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
793	        let _snap = SptHomeSnapshot::capture();
794	        let tmp = tempfile::tempdir().unwrap();
795	        std::env::set_var("SPT_HOME", tmp.path());
796
797	        let self_id = "doyle";
798	        // Pre-seed a PsycheStatus so read_status returns Some.
799	        crate::live::context::write_status(
800	            self_id,
801	            &crate::live::context::PsycheStatus {
802	                generation: 1,
803	                session_name: "doyle-psyche-1-00-00".to_string(),
804	                last_started: "2026-05-20T08:00:00-07:00".to_string(),
805	            },
806	        );
807
808	        let uuid = "deadbeef-1111-2222-3333-444455556666";
809	        WrapperState::emit_inwrapper_boot(self_id, uuid);
810
811	        let log = owlery::agent_worktree_path(self_id).join("sessions.log");
812	        let body = std::fs::read_to_string(&log).expect("sessions.log must exist after emit");
813	        let lines: Vec<&str> = body.lines().collect();
814	        assert_eq!(lines.len(), 1, "expected exactly one boot row; got: {:?}", body);
815	        assert!(lines[0].contains("\"trigger\":\"boot\""));
816	        assert!(lines[0].contains(&format!("\"session_uuid\":\"{}\"", uuid)));
817	    }
818
819	    /// Phase 24 Plan 04 — D-12 seal-on-roll end-to-end inside the wrapper:
820	    /// two consecutive `emit_inwrapper_boot` calls with DIFFERENT UUIDs
821	    /// must produce (a) a seal commit between them carrying the LITERAL
822	    /// `sessions: doyle seal gen N` subject for the OUTGOING gen, (b)
823	    /// the post-rotation sessions.log holds the NEW-gen boot row only,
824	    /// (c) `git show HEAD:sessions.log` recovers the prior-gen boot row.
825	    #[test]
826	    fn emit_inwrapper_boot_seals_on_second_call() {
827	        if !git_available() {
828	            eprintln!("skipping: git not on PATH");
829	            return;
830	        }
831	        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
832	        let _snap = SptHomeSnapshot::capture();
833	        let tmp = tempfile::tempdir().unwrap();
834	        std::env::set_var("SPT_HOME", tmp.path());
835
836	        let self_id = "doyle";
837	        crate::live::context::write_status(
838	            self_id,
839	            &crate::live::context::PsycheStatus {
840	                generation: 2,
841	                session_name: "doyle-psyche-2-00-00".to_string(),
842	                last_started: "2026-05-20T08:00:00-07:00".to_string(),
843	            },
844	        );
845
846	        // First gen-2 boot. Sessions.log starts with one row keyed by
847	        // uuid_a.
848	        let uuid_a = "11111111-aaaa-bbbb-cccc-dddddddddddd";
849	        WrapperState::emit_inwrapper_boot(self_id, uuid_a);
850
851	        // Bump status to gen=3 to mimic the next generation's start.
852	        crate::live::context::write_status(
853	            self_id,
854	            &crate::live::context::PsycheStatus {
855	                generation: 3,
856	                session_name: "doyle-psyche-3-00-00".to_string(),
857	                last_started: "2026-05-20T08:01:00-07:00".to_string(),
858	            },
859	        );
860
861	        // Second boot — seals gen 3 (the "prior" from this call's
862	        // perspective, read off read_status which we just bumped), then
863	        // appends a fresh boot row with uuid_b. NOTE: prior_gen is read
864	        // BEFORE the increment for the next boot in production; this
865	        // test just exercises that the seal-then-append happens.
866	        let uuid_b = "22222222-eeee-ffff-1111-222222222222";
867	        WrapperState::emit_inwrapper_boot(self_id, uuid_b);
868
869	        let wt = owlery::agent_worktree_path(self_id);
870	        let log = wt.join("sessions.log");
871	        let body = std::fs::read_to_string(&log).expect("sessions.log must exist");
872	        let lines: Vec<&str> = body.lines().collect();
873	        assert_eq!(
874	            lines.len(),
875	            1,
876	            "post-rotation: exactly one row (new-gen boot); got: {:?}",
877	            body
878	        );
879	        assert!(lines[0].contains(uuid_b), "post-rotation row keyed by uuid_b");
880	        assert!(!lines[0].contains(uuid_a), "uuid_a must NOT appear in current sessions.log");
881
882	        // (c) git log shows a seal commit for the OUTGOING gen (3 — read
883	        // off read_status at the moment the seal fired).
884	        let out = std::process::Command::new("git")
885	            .arg("-C")
886	            .arg(&wt)
887	            .args(["log", "--all", "--oneline"])
888	            .output()
889	            .expect("git log");
890	        let log_out = String::from_utf8_lossy(&out.stdout);
891	        assert!(
892	            log_out.contains("sessions: doyle seal gen 3"),
893	            "seal commit for gen 3 must appear in log; got: {}",
894	            log_out
895	        );
896
897	        // (d) HEAD:sessions.log recovers the prior-gen boot row.
898	        let out2 = std::process::Command::new("git")
899	            .arg("-C")
900	            .arg(&wt)
901	            .args(["show", "HEAD:sessions.log"])
902	            .output()
903	            .expect("git show");
904	        let recovered = String::from_utf8_lossy(&out2.stdout);
905	        assert!(
906	            recovered.contains(uuid_a),
907	            "git show HEAD:sessions.log must recover uuid_a row; got: {}",
908	            recovered
909	        );
910	    }
911	}
912

// Claude session management: init, resume, final invocations

use std::io::Write;
use std::process::{Command, Stdio};

use super::WrapperState;
use crate::common::owlery;
use crate::common::wrapper_state::{self as wrapper_state_helper, WrapperHandoffState};
use crate::live::context::git_commit_context;
use crate::live::wrapper::lifecycle;

/// Psyche template embedded at compile time
const PSYCHE_TEMPLATE: &str = include_str!("../../../psyche.md");

/// Build the --agents JSON string with substituted psyche.md template.
fn build_agents_json(self_id: &str, psyche_id: &str, period: u64) -> String {
    // Sentinel: period == 0 means "no scheduled pulse". Replace the FULL segment
    // `{{period}} seconds` so the identity-block line reads coherently in both
    // modes. psyche.md line 6 is exactly `Pulse period: {{period}} seconds`.
    let period_phrase: String = if period == 0 {
        "disabled (no scheduled pulses; event-driven wake only)".to_string()
    } else {
        format!("{} seconds", period)
    };
    let prompt = PSYCHE_TEMPLATE
        .replace("{{self_id}}", self_id)
        .replace("{{psyche_id}}", psyche_id)
        .replace("{{period}} seconds", &period_phrase);

    serde_json::json!({
        "owl-psyche": {
            "description": "Psyche agent for owl live system",
            "prompt": prompt,
            "tools": ["Read", "Write", "Edit"]
        }
    })
    .to_string()
}

impl WrapperState {
    /// Initialize a new Claude session. Returns the session UUID on success, exits on failure.
    pub fn init_session(&mut self) {
        let agents_json = build_agents_json(&self.self_id, &self.psyche_id, self.period);

        let pulse_phrase = if self.period == 0 {
            "no scheduled pulses".to_string()
        } else {
            format!("Pulse period: {}s", self.period)
        };
        let init_msg = format!(
            "You are now active. Begin monitoring for {}. {}. Generation: {}.",
            self.self_id, pulse_phrase, self.gen
        );

        let mut init_cmd = Command::new(&self.cli_binary);
        init_cmd.current_dir(owlery::psyche_dir());
        init_cmd.env_remove("OWL_SESSION_ID");
        init_cmd.args([
            "-p",
            "--agents",
            &agents_json,
            "--agent",
            "owl-psyche",
            "--name",
            &self.session_name,
            "--model",
            "sonnet",
            "--fallback-model",
            "opus",
            "--effort",
            "medium",
            "--dangerously-skip-permissions",
            "--disable-slash-commands",
            "--tools",
            "Read,Edit,Write",
            "--output-format",
            "json",
        ]);
        init_cmd.stdin(Stdio::piped());
        init_cmd.stdout(Stdio::piped());
        init_cmd.stderr(Stdio::null());
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            const CREATE_NO_WINDOW: u32 = 0x08000000;
            init_cmd.creation_flags(CREATE_NO_WINDOW);
        }

        // Phase 18.8 Pitfall 5: soft-stopped perches preserve owlery/<id>/ contents
        // including .last-commune-epoch. A fresh live session in the same perch
        // would otherwise see a stale cursor and filter out legitimate new turns.
        // Clear the marker here so the first echo-commune of this new session
        // uses the 64KB-tail fallback (marker-missing path).
        //
        // WR-08 (acknowledged): there is a narrow window during PULSE_TRIGGER
        // recovery where an in-flight echo-commune from the PREVIOUS session
        // (spawned before init_session ran) can re-write the cursor AFTER this
        // remove, overwriting the intentional clear. Low probability in practice
        // because echo-commune fires are wrapper-gated by .more-done sentinel.
        // The fallback on missing/stale cursor is "full 64KB tail" (safe
        // degradation), so accepting the risk per D-13/D-19 semantics. A
        // session-token file would be the correctness fix if this proves to
        // matter in practice.
        let _ = std::fs::remove_file(crate::common::owlery::last_commune_epoch_path(&self.self_id));

        self.log("spawning claude init...");
        let session_uuid = match init_cmd.spawn() {
            Ok(mut child) => {
                self.log(&format!("claude init spawned pid={}", child.id()));
                if let Some(mut stdin) = child.stdin.take() {
                    // IN-02: log stdin write failures instead of dropping silently.
                    if let Err(e) = stdin.write_all(init_msg.as_bytes()) {
                        self.log(&format!("[STDIN] init_session write failed: {}", e));
                    }
                }
                self.log("stdin written, waiting for output...");

                let output = child.wait_with_output().unwrap_or_else(|e| {
                    self.log(&format!("wait_with_output FAILED: {}", e));
                    std::process::exit(1);
                });

                let exit_code = output.status.code().unwrap_or(-1);
                let stdout = String::from_utf8_lossy(&output.stdout);
                let stderr = String::from_utf8_lossy(&output.stderr);
                self.log(&format!(
                    "claude init exited code={} stdout_len={} stderr_len={}",
                    exit_code,
                    stdout.len(),
                    stderr.len()
                ));
                self.log(&format!(
                    "stdout preview: {}",
                    stdout.chars().take(500).collect::<String>()
                ));
                if exit_code != 0 {
                    self.log(&format!(
                        "stderr: {}",
                        stderr.chars().take(500).collect::<String>()
                    ));
                }
                extract_session_uuid(&stdout)
            }
            Err(e) => {
                self.log(&format!("claude init spawn FAILED: {}", e));
                std::process::exit(1);
            }
        };

        match session_uuid {
            Some(uuid) => {
                self.log(&format!("init success, session_uuid={}", uuid));
                self.session_uuid = uuid;
                self.log("auto-commit: git_commit_context after init_session");
                git_commit_context(&owlery::psyche_dir(), &self.self_id);

                // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): if the LLM
                // wrote `agents/{self_id}/live_context.md` containing a
                // `<project-context>` slice, route it synchronously to
                // `projects/<resolved>/<self_id>.md` via the shared helper.
                // No-op if the project slice is missing (G2 self-heal lives
                // in Plan 25.3-06). Re-entry handled by RefCell guard.
                //
                // 25.3-06 D-G2-01: pass `Some(&init_msg)` so the helper can
                // evaluate G2 trigger condition (c) (prompt contains
                // `CURRENT_PROJECT_CONTEXT`). For init_session specifically,
                // `init_msg` is identity-only (no CURRENT_PROJECT_CONTEXT
                // block) — condition (c) will be false here so G2 NEVER
                // fires on init. Pass `self` as the production
                // ResumeRespawnDispatcher impl (delegates to
                // resume_session_with_exit).
                let _ = self.route_live_context_md_if_changed(Some(&init_msg), self);

                // Phase 24 Plan 04 — publish fresh session_uuid to
                // wrapper-state.json so external sessions-log emit sites
                // (start.rs / signoff.rs / echo_commune.rs) can read the
                // current UUID without contention against the
                // wrapper's destructive handoff load path. Soft-fail
                // per D-02 — the in-memory `self.session_uuid` is the
                // canonical source for in-wrapper emits; the file is a
                // best-effort mirror for external consumers.
                let state = WrapperHandoffState {
                    session_uuid: self.session_uuid.clone(),
                    gen: self.gen,
                    last_fresh_launch_epoch:
                        lifecycle::instant_to_epoch(self.last_fresh_launch),
                    // 260523-648: include pulse_psyche on every re-publish so
                    // external readers see the operator's choice.
                    pulse_psyche: self.pulse_psyche,
                };
                // Phase 25.4 D-03: migration-window CLOSED. Wrapper-state
                // writers route through `write_wrapper_state_at(self_id,
                // psyche_id, state)` — the central perch_path resolver places
                // the file at `owlery/<self>/nested/<psyche>/wrapper-state.json`
                // (CONTEXT D-04 explicit-parent). The reader
                // `wrapper_state_path_resolved` remains nested-first /
                // flat-fallback for one deploy cycle's handoff transition
                // (removal pending follow-up phase).
                if let Err(e) = wrapper_state_helper::write_wrapper_state_at(
                    &self.self_id,
                    &self.psyche_id,
                    &state,
                )
                {
                    self.log(&format!(
                        "wrapper_state publish after init_session failed: {} (soft-fail)",
                        e
                    ));
                }
            }
            None => {
                self.log("init FAILED: no session UUID captured");
                std::process::exit(1);
            }
        }
    }

    /// Resume the Claude session with a message (regular poll iteration).
    /// Kept for backward compatibility; main loop uses resume_session_checked().
    ///
    /// IN-05: previously an 87-line near-duplicate of resume_session_checked
    /// that had drifted (missing the git_commit_context auto-commit added to
    /// the checked variant). Reduced to a delegating one-liner — the two
    /// functions can no longer diverge, and the `#[allow(dead_code)]` symbol
    /// is preserved for any external skill / command-dispatch reference.
    #[allow(dead_code)]
    pub fn resume_session(&self, msg: &str) {
        let _ = self.resume_session_checked(msg);
    }

    /// Phase 30 Plan 02 Task 1 — exit-code-aware sibling of
    /// `resume_session_checked`. Same subprocess shape, same logging, same
    /// passive-context composition; the ONLY difference is the return type:
    /// `(Option<String>, i32)` where the second element is the captured
    /// `output.status.code().unwrap_or(-1)`.
    ///
    /// Exit-code semantics (i32 conventions):
    /// - `>= 0`: subprocess exited with this code (0 = success per POSIX).
    /// - `-1`: subprocess was terminated by a signal (no code available), OR
    ///   `wait_with_output` returned Err, OR `spawn()` failed.
    ///
    /// Phase 30 D8 requires deletion of `.claude/{id}-{kind}.md` ONLY on
    /// `exit_code == 0`; any non-zero (including the `-1` spawn/wait failure
    /// sentinel) MUST retain the file for retry on next consume.
    ///
    /// Canonical regression coverage for the `(Option<String>, i32)` tuple
    /// contract is provided by Plan 04's `tests/file_drop_integration.rs`
    /// Tests 2-3 (subprocess-driven integration). The 5 existing callers
    /// of `resume_session_checked` go through the 1-line wrapper below and
    /// see no behavior change.
    pub fn resume_session_with_exit(&self, msg: &str) -> (Option<String>, i32) {
        let trimmed = msg.trim();
        let first_line = trimmed.lines().next().unwrap_or("");
        let body_preview: String = trimmed
            .lines()
            .skip(1)
            .flat_map(|l| l.chars().chain(std::iter::once(' ')))
            .take(100)
            .collect();
        self.log(&format!(
            "[MSG] from={}: \"{}\"",
            first_line.chars().take(40).collect::<String>(),
            body_preview.trim()
        ));

        // 25.3-03 cycle-5 HIGH 3 — wrapper LLM-stdin boundary. Route the
        // incoming `msg` through `compose_llm_prompt_from_envelope` so that
        // if it is an EVENT envelope, the LLM sees a natural-language prompt
        // (header + decoded body) instead of `<EVENT>` framing tokens.
        // Non-envelope inputs (raw PULSE_TRIGGER, plain text) fall through
        // unchanged — the boundary helper is idempotent and defensive.
        let prompt = super::compose_llm_prompt_from_envelope(msg);

        let now = chrono::Local::now()
            .format("%Y-%m-%dT%H:%M:%S%:z")
            .to_string();
        let passive = self.compose_passive_context();
        let timestamped = format!("[Current time: {}]\n{}{}", now, prompt, passive);

        self.log_block(
            &format!("[STDIN] {} bytes to resume:", timestamped.len()),
            &timestamped,
        );

        let mut resume_cmd = Command::new(&self.cli_binary);
        resume_cmd.current_dir(owlery::psyche_dir());
        resume_cmd.env_remove("OWL_SESSION_ID");
        resume_cmd.args([
            "-p",
            "--resume",
            &self.session_uuid,
            "--model",
            "sonnet",
            "--fallback-model",
            "opus",
            "--effort",
            "medium",
            "--dangerously-skip-permissions",
            "--disable-slash-commands",
            "--tools",
            "Read,Edit,Write",
        ]);
        resume_cmd.stdin(Stdio::piped());
        resume_cmd.stdout(Stdio::piped());
        resume_cmd.stderr(Stdio::piped());
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            const CREATE_NO_WINDOW: u32 = 0x08000000;
            resume_cmd.creation_flags(CREATE_NO_WINDOW);
        }

        if let Ok(mut child) = resume_cmd.spawn() {
            if let Some(mut stdin) = child.stdin.take() {
                // IN-02: log stdin write failures.
                if let Err(e) = stdin.write_all(timestamped.as_bytes()) {
                    self.log(&format!(
                        "[STDIN] resume_session_with_exit write failed: {}",
                        e
                    ));
                }
            }
            match child.wait_with_output() {
                Ok(output) => {
                    let stdout = String::from_utf8_lossy(&output.stdout);
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    let exit_code = output.status.code().unwrap_or(-1);
                    if !stderr.trim().is_empty() {
                        self.log_block("[PSYCHE] resume stderr:", &stderr);
                    }
                    if !stdout.is_empty() {
                        let response_text = extract_claude_response(&stdout);
                        self.log_block(
                            &format!("[PSYCHE] resume (exit={}):", exit_code),
                            &response_text,
                        );
                        self.log("auto-commit: git_commit_context after resume_session_with_exit");
                        git_commit_context(&owlery::psyche_dir(), &self.self_id);

                        // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): if
                        // the LLM wrote `agents/{self_id}/live_context.md`
                        // containing a `<project-context>` slice, route it
                        // synchronously. When the project slice is missing,
                        // Plan 25.3-06's G2 self-heal fires (subject to
                        // D-G2-01 conditions a+b+c).
                        //
                        // 25.3-06 D-G2-01: pass `Some(&timestamped)` — the
                        // FULL prompt the LLM saw on its stdin this turn
                        // (header + decoded body + passive context). The
                        // passive context (via `compose_passive_context`)
                        // is what carries the `CURRENT_PROJECT_CONTEXT
                        // (name=...):` block when the project is
                        // resolvable. Greppable substring satisfies
                        // condition (c). `self` is the production
                        // ResumeRespawnDispatcher impl (delegates back to
                        // resume_session_with_exit — re-entry guarded by
                        // live_ctx_route_in_flight).
                        let _ = self.route_live_context_md_if_changed(Some(&timestamped), self);

                        // Phase 24 Plan 04 — sessions.log pulse trigger
                        // (D-11). The wrapper IS the writer; pass the
                        // in-memory `self.session_uuid` directly per Q6
                        // LOCKED disposition — no state-file read needed
                        // inside the wrapper. D-18 dedup means 50 pulses
                        // for one UUID produce ONE row whose ts advances.
                        // Soft-fail per D-02.
                        let _ = crate::common::tracked::append_session_entry(
                            &self.self_id,
                            &self.session_uuid,
                            "pulse",
                        );

                        return (Some(response_text), exit_code);
                    } else {
                        self.log(&format!(
                            "[PSYCHE] resume (exit={}): (no output)",
                            exit_code
                        ));
                        return (None, exit_code);
                    }
                }
                Err(e) => {
                    self.log(&format!("[PSYCHE] resume wait failed: {}", e));
                    return (None, -1);
                }
            }
        }
        (None, -1)
    }

    /// Resume the Claude session and return the response text if output was produced.
    /// Returns Some(response_text) on success, None on failure or empty output.
    ///
    /// Phase 30 Plan 02 Task 1: thin wrapper over `resume_session_with_exit`
    /// — discards the exit code. Preserves the existing public API and the
    /// 5 known caller sites' semantics (compile-time tested by `cargo build`
    /// and behaviourally tested by pre-existing wrapper tests).
    pub fn resume_session_checked(&self, msg: &str) -> Option<String> {
        let (resp, _exit_code) = self.resume_session_with_exit(msg);
        resp
    }

    /// Phase 24 Plan 04 — in-wrapper helper that emits one `boot` row to
    /// `agents/{self_id}/sessions.log` after a fresh init_session inside the
    /// wrapper process (24h-refresh, PULSE_TRIGGER tier-1/tier-2 recovery).
    ///
    /// Contrast with `start.rs::emit_boot_trigger_after_spawn`: that helper
    /// runs OUTSIDE the wrapper process and sources the UUID via
    /// `read_wrapper_state`. This helper runs INSIDE the wrapper and uses
    /// the in-memory UUID directly per Q6 LOCKED.
    ///
    /// Seals the prior generation's sessions.log via D-12 BEFORE appending
    /// the new boot row. All errors soft-fail per D-02.
    pub(crate) fn emit_inwrapper_boot(self_id: &str, session_uuid: &str) {
        let prior_gen = crate::live::context::read_status(self_id)
            .map(|s| s.generation)
            .unwrap_or(1);
        let _ = crate::common::tracked::seal_and_rotate_sessions_log(self_id, prior_gen);
        let _ = crate::common::tracked::append_session_entry(self_id, session_uuid, "boot");
        // Phase 24.1 D-06: bump tracked info.json last_started on the boot
        // trigger. Empty names + empty branch — boot does NOT touch project
        // fields (D-09 + D-10 reserve those for commune/signoff). Soft-fail
        // via `let _`. Pairs with start.rs::emit_boot_trigger_after_spawn —
        // both boot emit sites bump per RESEARCH §Pattern 2 lifecycle map.
        let _ = crate::common::owlery::bump_tracked_agent_info(
            self_id, "boot", &[], "",
        );
    }

    /// D-01: 3-tier PULSE_TRIGGER recovery when resume produces empty stdout.
    /// Tier 1: Fresh session (new init_session, no --resume)
    /// Tier 2: Try `ccs` CLI instead of `claude` -- latch on success
    /// Tier 3: Wait for next pulse interval (return without action)
    pub fn handle_pulse_trigger_recovery(&mut self, msg: &str) {
        // Tier 1: Fresh session (no --resume)
        self.log("PULSE_TRIGGER recovery tier 1: trying fresh session");
        self.init_session();
        self.last_fresh_launch = std::time::Instant::now();
        // Phase 24 Plan 04 — Tier 1 init_session is a fresh `claude -p`
        // with no --resume (D-11 `boot`). Seal the prior generation first
        // (D-12), then append a boot row keyed by the new self.session_uuid.
        // Soft-fail per D-02.
        Self::emit_inwrapper_boot(&self.self_id, &self.session_uuid);
        if self.resume_session_checked(msg).is_some() {
            self.log("PULSE_TRIGGER recovery tier 1 succeeded (fresh session)");
            return;
        }

        // Tier 2: Try `ccs` CLI
        self.log("PULSE_TRIGGER recovery tier 2: trying ccs CLI");
        let original_cli = self.cli_binary.clone();
        self.cli_binary = "ccs".to_string();
        self.init_session();
        self.last_fresh_launch = std::time::Instant::now();
        // Phase 24 Plan 04 — Tier 2 also init_sessions a fresh claude
        // (under the `ccs` CLI). Same boot-trigger emit.
        Self::emit_inwrapper_boot(&self.self_id, &self.session_uuid);
        if self.resume_session_checked(msg).is_some() {
            self.log("PULSE_TRIGGER recovery tier 2 succeeded -- LATCHING ccs as CLI for remainder of wrapper session");
            // cli_binary stays as "ccs" -- latched
            return;
        }

        // Tier 2 failed -- revert to original CLI
        self.cli_binary = original_cli;
        // Tier 3: Wait for next pulse interval
        self.log("PULSE_TRIGGER recovery tier 3: all tiers failed, waiting for next pulse interval");
        // No action -- the loop continues and will try again on next PULSE_TRIGGER
    }

    /// Fire one last Claude session invocation for INIT_SIGNOFF context save, then signal exit.
    /// Returns true to indicate the wrapper should break out of the loop.
    pub fn final_session(&self, msg: &str) -> bool {
        self.log("INIT_SIGNOFF detected, firing final context save");
        // 25.3-03 cycle-5 HIGH 3 — apply the same LLM-stdin boundary as
        // `resume_session_with_exit`. Inbound INIT_SIGNOFF arrives as an
        // EVENT envelope (composed via `compose_init_signoff_payload`); the
        // LLM must see the natural-language prompt, not the EVENT framing.
        let prompt = super::compose_llm_prompt_from_envelope(msg);
        let now = chrono::Local::now()
            .format("%Y-%m-%dT%H:%M:%S%:z")
            .to_string();
        let passive = self.compose_passive_context();
        let timestamped = format!("[Current time: {}]\n{}{}", now, prompt, passive);

        self.log_block(
            &format!("[STDIN] {} bytes to final:", timestamped.len()),
            &timestamped,
        );

        let mut final_cmd = Command::new(&self.cli_binary);
        final_cmd.current_dir(owlery::psyche_dir());
        final_cmd.env_remove("OWL_SESSION_ID");
        final_cmd.args([
            "-p",
            "--resume",
            &self.session_uuid,
            "--model",
            "sonnet",
            "--fallback-model",
            "opus",
            "--effort",
            "medium",
            "--dangerously-skip-permissions",
            "--disable-slash-commands",
            "--tools",
            "Read,Edit,Write",
        ]);
        final_cmd.stdin(Stdio::piped());
        final_cmd.stdout(Stdio::piped());
        final_cmd.stderr(Stdio::piped());
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            const CREATE_NO_WINDOW: u32 = 0x08000000;
            final_cmd.creation_flags(CREATE_NO_WINDOW);
        }
        if let Ok(mut child) = final_cmd.spawn() {
            if let Some(mut stdin) = child.stdin.take() {
                // IN-02: log stdin write failures.
                if let Err(e) = stdin.write_all(timestamped.as_bytes()) {
                    self.log(&format!("[STDIN] final_session write failed: {}", e));
                }
            }
            match child.wait_with_output() {
                Ok(output) => {
                    let stdout = String::from_utf8_lossy(&output.stdout);
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    let exit_code = output.status.code().unwrap_or(-1);
                    if !stdout.is_empty() {
                        let response_text = extract_claude_response(&stdout);
                        self.log_block(
                            &format!("[PSYCHE] final (exit={}):", exit_code),
                            &response_text,
                        );
                    } else {
                        self.log(&format!(
                            "[PSYCHE] final (exit={}): (no output)",
                            exit_code
                        ));
                    }
                    if !stderr.trim().is_empty() {
                        self.log_block("[PSYCHE] final stderr:", &stderr);
                    }
                }
                Err(e) => {
                    self.log(&format!("[PSYCHE] final wait failed: {}", e));
                }
            }
        }
        self.log("auto-commit: git_commit_context after final_session");
        git_commit_context(&owlery::psyche_dir(), &self.self_id);

        // 25.3-05 D-E-07 revised (Defect G HAPPY-PATH): final post-LLM-exit
        // route. Same helper as init_session + resume_session_with_exit.
        //
        // 25.3-06 D-G2-01: pass `None` for `last_prompt` — final_session
        // does NOT have a follow-on LLM call this iteration (the wrapper
        // is exiting). G2 must not fire here even if the project slice is
        // missing, since respawning the LLM via the corrective prompt
        // contradicts the "finalize and exit" semantic of the final-session
        // path. With `last_prompt = None`, condition (c) is unconditionally
        // false → helper returns SkippedNoProjectSlice (Plan 25.3-05
        // behavior preserved).
        let _ = self.route_live_context_md_if_changed(None, self);

        self.log("Final psyche invocation complete, wrapper exiting");
        true
    }

    /// Log a multi-line block: header with timestamp, then indented body lines.
    /// Emits `>>>  (empty)` when body is blank so the "header with colon + no body"
    /// pattern can never recur silently (debug: psyche-final-note-not-logged).
    fn log_block(&self, header: &str, body: &str) {
        self.log(header);
        if let Ok(mut f) = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.log_path)
        {
            if body.trim().is_empty() {
                let _ = writeln!(f, ">>>  (empty)");
            } else {
                for line in body.lines() {
                    let _ = writeln!(f, ">>>  {}", line);
                }
            }
        }
    }
}

/// Parse [REPLY], [NOTIFY], and [COMMUNE] markers from Psyche's response text.
/// Returns a list of (marker_type, content) tuples.
pub fn parse_markers(response: &str) -> Vec<(String, String)> {
    let mut markers = Vec::new();
    for marker_type in &["REPLY", "NOTIFY", "COMMUNE"] {
        let open_tag = format!("[{}]", marker_type);
        let close_tag = format!("[/{}]", marker_type);
        let mut search_from = 0;
        while let Some(start) = response[search_from..].find(&open_tag) {
            let content_start = search_from + start + open_tag.len();
            if let Some(end) = response[content_start..].find(&close_tag) {
                let content = response[content_start..content_start + end].trim().to_string();
                if !content.is_empty() {
                    markers.push((marker_type.to_string(), content));
                }
                search_from = content_start + end + close_tag.len();
            } else {
                break;
            }
        }
    }
    markers
}

/// Extract the human-readable response text from claude output.
///
/// Handles three shapes in order:
/// 1. `--output-format json`: whole-buffer JSON with top-level `result` string (init path)
/// 2. `--output-format stream-json`: JSONL where each line is an event; aggregates
///    text from `{type:"assistant", message:{content:[{type:"text", text:...}]}}`
///    events and falls back to a terminal `{result:"..."}` line if present
/// 3. Plain text (resume/final paths deliberately omit `--output-format` for
///    readable log blocks): returns `output.trim()`
///
/// Any shape that trims to empty returns "" — callers (and `log_block`) must
/// surface the empty case explicitly.
pub(crate) fn extract_claude_response(output: &str) -> String {
    let trimmed = output.trim();
    if trimmed.is_empty() {
        return String::new();
    }

    // Shape 1: whole-buffer JSON with "result"
    if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
        if let Some(result) = val.get("result").and_then(|v| v.as_str()) {
            return result.to_string();
        }
    }

    // Shape 2: stream-json (JSONL) — aggregate assistant text events
    let mut parts: Vec<String> = Vec::new();
    let mut final_result: Option<String> = None;
    let mut any_json_line = false;
    for line in trimmed.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let val = match serde_json::from_str::<serde_json::Value>(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        any_json_line = true;
        if val.get("type").and_then(|t| t.as_str()) == Some("assistant") {
            if let Some(content) = val.pointer("/message/content").and_then(|c| c.as_array()) {
                for item in content {
                    if item.get("type").and_then(|t| t.as_str()) == Some("text") {
                        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
                            parts.push(text.to_string());
                        }
                    }
                }
            }
        }
        if let Some(result) = val.get("result").and_then(|v| v.as_str()) {
            final_result = Some(result.to_string());
        }
    }
    if any_json_line {
        if !parts.is_empty() {
            return parts.join("\n");
        }
        if let Some(result) = final_result {
            return result;
        }
    }

    // Shape 3: plain text
    trimmed.to_string()
}

/// Standard UUID string length: 8-4-4-4-12 hex chars + 4 dashes = 36.
/// IN-03: named constant replaces the magic number `36` across this module.
const UUID_LEN: usize = 36;

/// JSON key prefix for session_id extraction.
/// IN-03: named constant replaces the magic offset `14` which was
/// `"\"session_id\":\"".len()`.
const SESSION_ID_KEY: &str = "\"session_id\":\"";

/// Extract a session UUID from claude JSON output.
/// Tries "session_id":"<uuid>" first, then falls back to UUID regex pattern.
fn extract_session_uuid(output: &str) -> Option<String> {
    // Try JSON key extraction: "session_id":"<uuid>"
    if let Some(start) = output.find(SESSION_ID_KEY) {
        let rest = &output[start + SESSION_ID_KEY.len()..];
        if let Some(end) = rest.find('"') {
            let uuid = &rest[..end];
            if is_uuid_like(uuid) {
                return Some(uuid.to_string());
            }
        }
    }

    // Fallback: find any UUID-like pattern (8-4-4-4-12 hex chars)
    // WR-05: guard against slicing at non-char-boundaries. If `output` contains
    // multi-byte UTF-8 (non-ASCII error text, smart quotes from locale-dependent
    // error prose, or mixed stderr capture), slicing `&output[i..i+UUID_LEN]` at a
    // non-char-boundary panics before is_uuid_like runs. Skip those indices.
    let mut i = 0;
    let bytes = output.as_bytes();
    while i + UUID_LEN <= bytes.len() {
        if output.is_char_boundary(i) && output.is_char_boundary(i + UUID_LEN) {
            let candidate = &output[i..i + UUID_LEN];
            if is_uuid_like(candidate) {
                return Some(candidate.to_string());
            }
        }
        i += 1;
    }

    None
}

/// Check if a string matches UUID format: 8-4-4-4-12 hex digits
fn is_uuid_like(s: &str) -> bool {
    if s.len() != UUID_LEN {
        return false;
    }
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 5 {
        return false;
    }
    let expected_lens = [8, 4, 4, 4, 12];
    for (part, &expected) in parts.iter().zip(&expected_lens) {
        if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
            return false;
        }
    }
    true
}

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

    /// Env-mutating tests serialize on this lock — same pattern as the
    /// other Phase 24 test modules.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct SptHomeSnapshot(Option<String>);
    impl SptHomeSnapshot {
        fn capture() -> Self { Self(std::env::var("SPT_HOME").ok()) }
    }
    impl Drop for SptHomeSnapshot {
        fn drop(&mut self) {
            match &self.0 {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    fn git_available() -> bool {
        std::process::Command::new("git")
            .arg("--version")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }

    /// Phase 24 Plan 04 — `emit_inwrapper_boot` is the in-wrapper sibling of
    /// `start.rs::emit_boot_trigger_after_spawn`. It seals the prior gen's
    /// sessions.log AND appends a single `boot` row keyed by the in-memory
    /// `session_uuid` (no state-file read inside the wrapper per Q6 LOCKED).
    ///
    /// First-ever invocation (no sessions.log yet): seal is a no-op (Task 1
    /// noop_on_first_boot test) and append creates the file with one row.
    /// Second invocation with a NEW uuid: seal commits the prior content
    /// and the file is truncated, then the new boot row is appended fresh
    /// (sessions.log has ONE row only, with the new UUID).
    #[test]
    fn emit_inwrapper_boot_first_call_appends_single_boot_row() {
        if !git_available() {
            eprintln!("skipping: git not on PATH");
            return;
        }
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = SptHomeSnapshot::capture();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());

        let self_id = "doyle";
        // Pre-seed a PsycheStatus so read_status returns Some.
        crate::live::context::write_status(
            self_id,
            &crate::live::context::PsycheStatus {
                generation: 1,
                session_name: "doyle-psyche-1-00-00".to_string(),
                last_started: "2026-05-20T08:00:00-07:00".to_string(),
            },
        );

        let uuid = "deadbeef-1111-2222-3333-444455556666";
        WrapperState::emit_inwrapper_boot(self_id, uuid);

        let log = owlery::agent_worktree_path(self_id).join("sessions.log");
        let body = std::fs::read_to_string(&log).expect("sessions.log must exist after emit");
        let lines: Vec<&str> = body.lines().collect();
        assert_eq!(lines.len(), 1, "expected exactly one boot row; got: {:?}", body);
        assert!(lines[0].contains("\"trigger\":\"boot\""));
        assert!(lines[0].contains(&format!("\"session_uuid\":\"{}\"", uuid)));
    }

    /// Phase 24 Plan 04 — D-12 seal-on-roll end-to-end inside the wrapper:
    /// two consecutive `emit_inwrapper_boot` calls with DIFFERENT UUIDs
    /// must produce (a) a seal commit between them carrying the LITERAL
    /// `sessions: doyle seal gen N` subject for the OUTGOING gen, (b)
    /// the post-rotation sessions.log holds the NEW-gen boot row only,
    /// (c) `git show HEAD:sessions.log` recovers the prior-gen boot row.
    #[test]
    fn emit_inwrapper_boot_seals_on_second_call() {
        if !git_available() {
            eprintln!("skipping: git not on PATH");
            return;
        }
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = SptHomeSnapshot::capture();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());

        let self_id = "doyle";
        crate::live::context::write_status(
            self_id,
            &crate::live::context::PsycheStatus {
                generation: 2,
                session_name: "doyle-psyche-2-00-00".to_string(),
                last_started: "2026-05-20T08:00:00-07:00".to_string(),
            },
        );

        // First gen-2 boot. Sessions.log starts with one row keyed by
        // uuid_a.
        let uuid_a = "11111111-aaaa-bbbb-cccc-dddddddddddd";
        WrapperState::emit_inwrapper_boot(self_id, uuid_a);

        // Bump status to gen=3 to mimic the next generation's start.
        crate::live::context::write_status(
            self_id,
            &crate::live::context::PsycheStatus {
                generation: 3,
                session_name: "doyle-psyche-3-00-00".to_string(),
                last_started: "2026-05-20T08:01:00-07:00".to_string(),
            },
        );

        // Second boot — seals gen 3 (the "prior" from this call's
        // perspective, read off read_status which we just bumped), then
        // appends a fresh boot row with uuid_b. NOTE: prior_gen is read
        // BEFORE the increment for the next boot in production; this
        // test just exercises that the seal-then-append happens.
        let uuid_b = "22222222-eeee-ffff-1111-222222222222";
        WrapperState::emit_inwrapper_boot(self_id, uuid_b);

        let wt = owlery::agent_worktree_path(self_id);
        let log = wt.join("sessions.log");
        let body = std::fs::read_to_string(&log).expect("sessions.log must exist");
        let lines: Vec<&str> = body.lines().collect();
        assert_eq!(
            lines.len(),
            1,
            "post-rotation: exactly one row (new-gen boot); got: {:?}",
            body
        );
        assert!(lines[0].contains(uuid_b), "post-rotation row keyed by uuid_b");
        assert!(!lines[0].contains(uuid_a), "uuid_a must NOT appear in current sessions.log");

        // (c) git log shows a seal commit for the OUTGOING gen (3 — read
        // off read_status at the moment the seal fired).
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&wt)
            .args(["log", "--all", "--oneline"])
            .output()
            .expect("git log");
        let log_out = String::from_utf8_lossy(&out.stdout);
        assert!(
            log_out.contains("sessions: doyle seal gen 3"),
            "seal commit for gen 3 must appear in log; got: {}",
            log_out
        );

        // (d) HEAD:sessions.log recovers the prior-gen boot row.
        let out2 = std::process::Command::new("git")
            .arg("-C")
            .arg(&wt)
            .args(["show", "HEAD:sessions.log"])
            .output()
            .expect("git show");
        let recovered = String::from_utf8_lossy(&out2.stdout);
        assert!(
            recovered.contains(uuid_a),
            "git show HEAD:sessions.log must recover uuid_a row; got: {}",
            recovered
        );
    }
}


on psyche's channels: perch and file drop input is processed by its wrapper today, then the psyche is spawned with the message payloads as context. the psyche's spawned instance only has access to rea…


Confirmed — you're right, and my "psyche sends via its own perch" was wrong. The sister source proves the sandbox+stdout-marker model. Let me check whether spt-core has ported the marker parser yet.


Bash

cd "C:/Users/decid/Documents/projects" && echo "===sister: marker relay (who acts on parse_markers)===" && grep -rn "parse_markers\|\[REPLY\]\|\[NOTIFY\]\|\[COMMUNE\]" claude_skill_owl/src/ | grep -v "fn parse_markers" | head -15; echo "===spt-core: any REPLY/NOTIFY/COMMUNE marker parsing?===" && grep -rn "REPLY\]\|NOTIFY\]\|COMMUNE\]\|parse_markers\|marker" spt-core/crates/ 2>/dev/null | head; echo "===spt-core marker hits count==="; grep -rln "\[REPLY\]\|\[NOTIFY\]\|parse_marker" spt-core/crates/ 2>/dev/null | head

Sister marker relay + spt-core marker parser presence


Command running in background with ID: b3gbm3prz. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b3gbm3prz.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>b3gbm3prz</task-id>
<tool-use-id>toolu_01NxmPkKnjmzPWsaDdtJcepE</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b3gbm3prz.output</output-file>
<status>completed</status>
<summary>Background command "Sister marker relay + spt-core marker parser presence" completed (exit code 0)</summary>
</task-notification>


1	===sister: marker relay (who acts on parse_markers)===
2	claude_skill_owl/src/live/wrapper/claude.rs:592:/// Parse [REPLY], [NOTIFY], and [COMMUNE] markers from Psyche's response text.
3	claude_skill_owl/src/live/wrapper/mod.rs:1374:                let markers = claude::parse_markers(response_text);
4	claude_skill_owl/src/owl/echo_commune.rs:3:// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
5	claude_skill_owl/src/owl/echo_commune.rs:36://           sentinel owns triggering. Empty [COMMUNE][/COMMUNE] is a
6	claude_skill_owl/src/owl/echo_commune.rs:203:/// Phase 29 Plan 01 Task 3: testable seam for the [COMMUNE]-marker
7	claude_skill_owl/src/owl/echo_commune.rs:225:/// `[COMMUNE]` content for a marker, may return `Some(replacement_body)` to
8	claude_skill_owl/src/owl/echo_commune.rs:706:/// the Psyche LLM in its `[COMMUNE]` marker bodies after the psyche.md edit),
9	claude_skill_owl/src/owl/echo_commune.rs:742:/// path inside `run_echo_commune` (after parsing [COMMUNE] markers + before
10	claude_skill_owl/src/owl/echo_commune.rs:1000:/// Both prompts share the SAME `[COMMUNE]` two-slice envelope output contract
11	claude_skill_owl/src/owl/echo_commune.rs:1028:             Output format — wrap the [COMMUNE] body in the two-slice envelope \
12	claude_skill_owl/src/owl/echo_commune.rs:1034:             [COMMUNE]\n\
13	claude_skill_owl/src/owl/echo_commune.rs:1057:             Output format — wrap the [COMMUNE] body in the two-slice envelope \
14	claude_skill_owl/src/owl/echo_commune.rs:1062:             [COMMUNE]\n\
15	claude_skill_owl/src/owl/echo_commune.rs:1073:             If nothing significant happened, output an empty [COMMUNE][/COMMUNE]."
16	claude_skill_owl/src/owl/echo_commune.rs:1079:/// `[COMMUNE]` slice body is "effectively empty" — i.e. BOTH the
17	===spt-core: any REPLY/NOTIFY/COMMUNE marker parsing?===
18	spt-core/crates/spt/src/api/live.rs:64:/// marker. Auth-gated by the dispatcher.
19	spt-core/crates/spt/src/api/live.rs:82:            // Non-live endpoint: soft-stop only (remove the online marker).
20	spt-core/crates/spt/src/api/reporting.rs:64:        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
21	spt-core/crates/spt/src/api/startup.rs:120:/// Write the perch record (skeleton→live) + ready marker + capability token.
22	spt-core/crates/spt/src/api/worker.rs:12://! marker (the soft-cleanup signal; `worker-stop` removes it). When live
23	spt-core/crates/spt/src/api/worker.rs:49:    // ready marker = the live-child signal the cascade-wipe guard checks.
24	spt-core/crates/spt/src/api/worker.rs:61:/// remove its `ready` marker so it no longer counts as a live child; the
25	spt-core/crates/spt/src/api/worker.rs:69:        eprintln!("WORKER_STOPPED:{id} (no ready marker)");
26	spt-core/crates/spt/src/api/worker.rs:93:/// `ready` marker). Empty ⇒ the parent is safe to hard-delete.
27	spt-core/crates/spt/src/api/worker.rs:106:        // A child is live while its ready marker exists (info.json present =
28	===spt-core marker hits count===
29

===sister: marker relay (who acts on parse_markers)===
claude_skill_owl/src/live/wrapper/claude.rs:592:/// Parse [REPLY], [NOTIFY], and [COMMUNE] markers from Psyche's response text.
claude_skill_owl/src/live/wrapper/mod.rs:1374:                let markers = claude::parse_markers(response_text);
claude_skill_owl/src/owl/echo_commune.rs:3:// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
claude_skill_owl/src/owl/echo_commune.rs:36://           sentinel owns triggering. Empty [COMMUNE][/COMMUNE] is a
claude_skill_owl/src/owl/echo_commune.rs:203:/// Phase 29 Plan 01 Task 3: testable seam for the [COMMUNE]-marker
claude_skill_owl/src/owl/echo_commune.rs:225:/// `[COMMUNE]` content for a marker, may return `Some(replacement_body)` to
claude_skill_owl/src/owl/echo_commune.rs:706:/// the Psyche LLM in its `[COMMUNE]` marker bodies after the psyche.md edit),
claude_skill_owl/src/owl/echo_commune.rs:742:/// path inside `run_echo_commune` (after parsing [COMMUNE] markers + before
claude_skill_owl/src/owl/echo_commune.rs:1000:/// Both prompts share the SAME `[COMMUNE]` two-slice envelope output contract
claude_skill_owl/src/owl/echo_commune.rs:1028:             Output format — wrap the [COMMUNE] body in the two-slice envelope \
claude_skill_owl/src/owl/echo_commune.rs:1034:             [COMMUNE]\n\
claude_skill_owl/src/owl/echo_commune.rs:1057:             Output format — wrap the [COMMUNE] body in the two-slice envelope \
claude_skill_owl/src/owl/echo_commune.rs:1062:             [COMMUNE]\n\
claude_skill_owl/src/owl/echo_commune.rs:1073:             If nothing significant happened, output an empty [COMMUNE][/COMMUNE]."
claude_skill_owl/src/owl/echo_commune.rs:1079:/// `[COMMUNE]` slice body is "effectively empty" — i.e. BOTH the
===spt-core: any REPLY/NOTIFY/COMMUNE marker parsing?===
spt-core/crates/spt/src/api/live.rs:64:/// marker. Auth-gated by the dispatcher.
spt-core/crates/spt/src/api/live.rs:82:            // Non-live endpoint: soft-stop only (remove the online marker).
spt-core/crates/spt/src/api/reporting.rs:64:        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
spt-core/crates/spt/src/api/startup.rs:120:/// Write the perch record (skeleton→live) + ready marker + capability token.
spt-core/crates/spt/src/api/worker.rs:12://! marker (the soft-cleanup signal; `worker-stop` removes it). When live
spt-core/crates/spt/src/api/worker.rs:49:    // ready marker = the live-child signal the cascade-wipe guard checks.
spt-core/crates/spt/src/api/worker.rs:61:/// remove its `ready` marker so it no longer counts as a live child; the
spt-core/crates/spt/src/api/worker.rs:69:        eprintln!("WORKER_STOPPED:{id} (no ready marker)");
spt-core/crates/spt/src/api/worker.rs:93:/// `ready` marker). Empty ⇒ the parent is safe to hard-delete.
spt-core/crates/spt/src/api/worker.rs:106:        // A child is live while its ready marker exists (info.json present =
===spt-core marker hits count===


Prompt:
<task-notification>
<task-id>b3gbm3prz</task-id>
<tool-use-id>toolu_01NxmPkKnjmzPWsaDdtJcepE</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b3gbm3prz.output</output-file>
<status>completed</status>
<summary>Background command "Sister marker relay + spt-core marker parser presence" completed (exit code 0)</summary>
</task-notification>


Confirmed in full. You're right; I was wrong. The sister source is unambiguous.

**`claude_skill_owl` psyche I/O model (verified):**

- **Sandboxed to Read/Write/Edit.** `claude.rs:34` `"tools":["Read","Write","Edit"]`, and every invocation passes `--tools "Read,Edit,Write"` (lines 75, 301, 507). No Bash, no network, no send tool. The psyche LLM **cannot send messages itself.**
- **Inbound = stdin.** Wrapper composes the incoming message/event (`compose_llm_prompt_from_envelope` + passive context) and writes it to the psyche's **stdin** (`stdin.write_all(timestamped...)`, line 316). The psyche does not read the spool itself.
- **Outbound = stdout markers only.** Wrapper captures the psyche's stdout (`wait_with_output`, `extract_claude_response`), then `parse_markers` (claude.rs:592-614) pulls `[REPLY]…[/REPLY]`, `[NOTIFY]…[/NOTIFY]`, `[COMMUNE]…[/COMMUNE]`. `wrapper/mod.rs:1374` acts on them — **the wrapper is the outbound proxy** that holds send/notify capability and relays on the psyche's behalf. The psyche's perch is **inbound addressing only**.

So my "psyche replies via its own perch/messaging" was flat wrong. Reply path = **formatted stdout → wrapper parses → wrapper sends.** Exactly as you said.

**The spt-core gap this exposes (real, and it matters for M4):**

1. **`spawn_session` null's the reply channel.** `runtime.rs:93-95` sets `stdout(Stdio::null())` on the detached psyche. That discards the psyche's *only* outbound channel. As interim-wired, an spt-core live psyche **cannot reply at all.** (echo-commune works *only* because it uses the *bounded* `run_bounded_stdin`, which captures stdout — `runtime.rs:304`.)

2. **spt-core has NOT ported `parse_markers`.** Grep finds zero `[REPLY]`/`[NOTIFY]`/`parse_markers` in spt-core (the "marker" hits are unrelated ready/online file markers). spt-core's echo path treats *whole stdout* as the commune body (`echo.rs` `stamp_provenance(SOURCE_ECHO_COMMUNE, &out.stdout)`) — the COMMUNE case only, by implication. **REPLY (psyche→agent) and NOTIFY (psyche→user) parsing don't exist yet.**

3. **Correct target model** (and it's what ADR-0004 already implies — *"psyche = loop inside the daemon, summarizer subprocess ephemeral"*): drive the psyche as a **bounded, stdin-fed, stdout-captured** invocation; **parse REPLY/NOTIFY/COMMUNE**; the **daemon is the outbound proxy** (it holds send + the D8 notif capability; the sandboxed psyche never does). The detached `Stdio::null()` spawn is interim scaffolding that must NOT become the live-psyche driver.

**This wires straight into M4:**
- **D8 notif** = the `[NOTIFY]` relay half (daemon issues the notif the psyche requested).
- **`[REPLY]` relay** = daemon-proxied psyche→agent message.
- Both block on: psyche driven with **captured stdout + a ported `parse_markers`**. Currently neither exists in spt-core.

This is a missing M4 work item, not just a doc note. Proposed, want me to:
1. Add a **D-task** to M4-PLAN.md — "psyche reply channel: bounded stdin-fed/stdout-captured psyche driver + `parse_markers` (REPLY/NOTIFY/COMMUNE) + daemon-as-send-proxy," with D8-notif depending on it;
2. Add a **KNOWN-HAZARD** — "`spawn_session` `Stdio::null()` discards the psyche's sole reply channel; the live-psyche driver MUST capture stdout" (with a `REQ-HAZARD-*` so it's conformance-gated);
3. Save the corrected psyche I/O model to memory (supersede my wrong note)?

All three?


be careful to not conflate the echo commune summarizer and the psyche. summarizer is a lightweight model separate from psyche that builds the echo commune. its output is fed into the psyche. psyche would never output COMMUNE parse marker, only REPLY or NOTIFY. --- proceed with all steps as recommended. note that instead of using memory, you should consult the documentation regimen in /grill-with-docs.


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


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


Important correction noted — summarizer ≠ psyche. Verifying the separation in the sister before I bake it into docs: the summarizer = separate cheap model emitting `[COMMUNE]`, fed *into* psyche; psyche emits only `[REPLY]`/`[NOTIFY]`.


cd "C:/Users/decid/Documents/projects/claude_skill_owl" && echo "===echo_commune: separate cheap model? + delivery to psyche===" && sed -n '1,45p' src/owl/echo_commune.rs; echo "===model used by summarizer (haiku/cheap?)==="; grep -n "model\|haiku\|sonnet\|--model\|cli\|Command::new\|deliver\|stdin\|to psyche\|to Psyche\|feeds\|inject" src/owl/echo_commune.rs | head -25

Confirm summarizer separate model + delivery to psyche


Command running in background with ID: b7rku18av. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b7rku18av.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>b7rku18av</task-id>
<tool-use-id>toolu_01Efa1AQhDwSvNhxeEy1HAXR</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b7rku18av.output</output-file>
<status>completed</status>
<summary>Background command "Confirm summarizer separate model + delivery to psyche" completed (exit code 0)</summary>
</task-notification>