<system-reminder>[Truncated: PARTIAL view — showing lines 1-1309 of 1749 total (28376 tokens, cap 25000). Call Read with offset=1310 limit=1309 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	// Plugin-mode SessionStart hook handler.
2	// 1. Injects $OWL and $LIVE env vars via CLAUDE_ENV_FILE
3	// 2. Writes OWL_SESSION_ID from Claude stdin to CLAUDE_ENV_FILE
4	// 3. Version trampoline: scans sibling plugin cache dirs for newer owl.exe,
5	//    rewrites CLAUDE_ENV_FILE to point to the newest version
6	// 4. Delegates to session-resume logic (existing resume::run)
7
8	use std::io::Write;
9
10	// =============================================================================
11	// Phase 33 Plan 03 AUTO-03/04: <spt-live-auto-pick> emission + allow-list
12	// =============================================================================
13	//
14	// Pure predicate `should_emit_auto_pick` decides whether the SessionStart hook
15	// should emit a `<spt-live-auto-pick>` block (wrapped in a hookSpecificOutput
16	// envelope JSON line) on the current SessionStart event. The predicate body
17	// itself does ZERO env reads — env values are captured up-front into
18	// `EnvSnapshot::capture()` so the predicate is testable as a pure function
19	// over (EnvSnapshot, SourceInput, bool) tuples.
20	//
21	// Silent-rejection contract (AUTO-04): on reject, the call site has ZERO
22	// stdout AND ZERO stderr side effects so subagent / wrapper / handoff
23	// transcripts never see a stray `auto-pick` token.
24
25	/// Phase 33 AUTO-03: snapshot of the five named env vars that gate
26	/// auto-pick emission. Reads happen ONLY inside `capture()` so the
27	/// predicate body is pure.
28	pub(crate) struct AutoPickEnvSnapshot {
29	    pub(crate) handoff_child: bool,         // OWL_HANDOFF_CHILD
30	    pub(crate) trampoline_guard: bool,      // SPT_TRAMPOLINE_GUARD
31	    pub(crate) echo_commune: bool,          // OWL_ECHO_COMMUNE
32	    pub(crate) skip_resume: bool,           // OWL_SKIP_RESUME
33	    pub(crate) team_name: bool,             // CLAUDE_CODE_TEAM_NAME
34	}
35
36	impl AutoPickEnvSnapshot {
37	    /// Capture env-var presence into a snapshot. Reads happen once at
38	    /// `run()` entry; the predicate operates on snapshot values so unit
39	    /// tests can construct snapshots directly without env mutation.
40	    pub(crate) fn capture() -> Self {
41	        Self {
42	            handoff_child: std::env::var("OWL_HANDOFF_CHILD").is_ok(),
43	            trampoline_guard: std::env::var("SPT_TRAMPOLINE_GUARD").is_ok(),
44	            echo_commune: std::env::var("OWL_ECHO_COMMUNE").is_ok(),
45	            skip_resume: std::env::var("OWL_SKIP_RESUME").is_ok(),
46	            team_name: std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok(),
47	        }
48	    }
49	}
50
51	/// Phase 33 AUTO-03: minimal slice of SessionStart hook stdin JSON that
52	/// the predicate needs. `source` is the fresh-vs-clear-vs-compact
53	/// discriminator; `agent_type` distinguishes psyche-wrapper subagent
54	/// sessions.
55	#[derive(Default, Clone, Debug)]
56	pub(crate) struct AutoPickSourceInput {
57	    pub(crate) source: Option<String>,
58	    pub(crate) agent_type: Option<String>,
59	}
60
61	/// Parse the auto-pick relevant fields from SessionStart stdin JSON.
62	/// Returns a default (empty) snapshot on parse error — never panics. A
63	/// default snapshot will fail the `source == "startup"` gate, so a
64	/// malformed-input degrades-safe to silent rejection.
65	pub(crate) fn parse_auto_pick_source_input(input: &str) -> AutoPickSourceInput {
66	    #[derive(serde::Deserialize, Default)]
67	    struct Raw {
68	        #[serde(default)]
69	        source: Option<String>,
70	        #[serde(default)]
71	        agent_type: Option<String>,
72	    }
73	    let parsed: Raw = serde_json::from_str(input).unwrap_or_default();
74	    AutoPickSourceInput {
75	        source: parsed.source,
76	        agent_type: parsed.agent_type,
77	    }
78	}
79
80	/// Pure predicate — returns true iff AUTO-03 should emit
81	/// `<spt-live-auto-pick>` on this SessionStart event.
82	///
83	/// Gates (in order, each returning false on rejection):
84	/// 1. `src.source` MUST be `Some("startup")` (rejects clear / compact /
85	///    None / any unknown token).
86	/// 2. `src.agent_type` MUST be `None` (rejects psyche / haiku / any
87	///    Claude-Code subagent dispatch).
88	/// 3. `env.handoff_child` OR `env.trampoline_guard` rejects
89	///    (mid-handoff / mid-trampoline successor session).
90	/// 4. `env.echo_commune` rejects (echo-commune recursion guard so a
91	///    haiku subprocess never sees an auto-pick block in its transcript).
92	/// 5. `env.skip_resume` OR `env.team_name` rejects (scripted opt-out OR
93	///    Claude Code team-member session).
94	/// 6. `parent_pid_has_active_perch == true` rejects (skill already
95	///    attached — would conflict with `<owl-active-perch>` emission).
96	///
97	/// On false, the caller emits NOTHING — no stdout, no stderr (AUTO-04
98	/// silent-rejection contract).
99	pub(crate) fn should_emit_auto_pick(
100	    env: &AutoPickEnvSnapshot,
101	    src: &AutoPickSourceInput,
102	    parent_pid_has_active_perch: bool,
103	) -> bool {
104	    // Gate 1: source MUST be "startup" (not clear/compact/None/other).
105	    // Case-sensitive match — `STARTUP` / `Startup` / `""` all reject.
106	    match src.source.as_deref() {
107	        Some("startup") => {}
108	        _ => return false,
109	    }
110	    // Gate 2: no agent_type (rejects psyche / haiku wrapper sessions).
111	    if src.agent_type.is_some() {
112	        return false;
113	    }
114	    // Gate 3: no handoff / trampoline successor (mid-binary-swap).
115	    if env.handoff_child || env.trampoline_guard {
116	        return false;
117	    }
118	    // Gate 4: no echo-commune recursion (haiku subprocess guard).
119	    if env.echo_commune {
120	        return false;
121	    }
122	    // Gate 5: no scripted opt-out / no team-member session.
123	    if env.skip_resume || env.team_name {
124	        return false;
125	    }
126	    // Gate 6: no active perch already attached to this parent_pid.
127	    // Skill is already running — would conflict with <owl-active-perch>.
128	    if parent_pid_has_active_perch {
129	        return false;
130	    }
131	    true
132	}
133
134	/// Phase 33 AUTO-03: emit `<spt-live-auto-pick>{pick-spec JSON}</spt-live-auto-pick>`
135	/// inside a hookSpecificOutput SessionStart envelope JSON line. Pattern S2
136	/// mirror of `resume.rs::inject_reorientation` at lines 415-424. Single
137	/// `println!` emission; NO `eprintln!` anywhere (silent-rejection contract
138	/// per AUTO-04 / Pattern S7).
139	pub(crate) fn emit_auto_pick(pick_spec_json: &str) {
140	    let context = format!(
141	        "<spt-live-auto-pick>\n{}\n</spt-live-auto-pick>",
142	        pick_spec_json
143	    );
144	    let response = serde_json::json!({
145	        "hookSpecificOutput": {
146	            "hookEventName": "SessionStart",
147	            "additionalContext": context,
148	        }
149	    });
150	    let out = serde_json::to_string(&response).unwrap_or_else(|_| {
151	        r#"{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":""}}"#
152	            .to_string()
153	    });
154	    println!("{}", out);
155	}
156
157	// Phase 35 Plan 10 (SC1 gap-closure): the passive emitter
158	// (`should_emit_sync_prompt` / `build_sync_prompt_envelope` / `emit_sync_prompt`)
159	// was REMOVED from this file. The offer now renders via ACTIVE owl-message
160	// delivery — see `crate::owl::sync_prompt` (predicate relocated there;
161	// `deliver_sync_prompt_to_perch` is the new entry point invoked from $LIVE
162	// start/revive and the clear/compact boundary below).
163
164	pub fn run() {
165	    // Step 1: Write env vars to CLAUDE_ENV_FILE
166	    write_env_vars();
167
168	    // Step 2: Read all of stdin once (stdin can only be consumed once)
169	    let mut input = String::new();
170	    let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut input);
171
172	    // Step 3: Extract session_id from stdin JSON, write OWL_SESSION_ID to env file
173	    write_session_id(&input);
174
175	    // Step 3a (Phase 29 D3): snapshot info.json.session_id BEFORE Phase 18.8.1
176	    // refresh overwrites it. The snapshot is the PRIOR session UUID; threaded
177	    // into FIRE_ECHO_COMMUNE_NOW so echo-commune can diff the prior jsonl.
178	    // ORDERING IS NON-NEGOTIABLE: must run AFTER write_session_id and BEFORE
179	    // refresh_info_json_session_id (Pitfall 1 in 29-RESEARCH.md).
180	    let prior_session_id = snapshot_prior_session_id(&input);
181
182	    // Step 3b (Phase 18.8.1 REQ-REFRESH-01): refresh owlery/{id}/info.json.session_id
183	    // so echo-commune diffs the correct jsonl after Claude rotates its session.
184	    // Closes debug/doyle-listener-info-json-stale-session-id.md.
185	    refresh_info_json_session_id(&input);
186
187	    // Step 4: Version trampoline -- rewrite CLAUDE_ENV_FILE to newest version
188	    version_trampoline();
189
190	    // Step 5: Sync OWL/LIVE into settings.json (mirrors env vars for Windows Bash tool compatibility)
191	    sync_settings_json();
192
193	    // Step 6: Check source field -- clear/compact get re-orientation instead of resume
194	    if inject_reorientation_if_needed(&input, prior_session_id.as_deref()) {
195	        return; // Re-orientation injected, skip resume
196	    }
197
198	    // Step 6.5 (Phase 33 Plan 03 AUTO-03): emit <spt-live-auto-pick> on
199	    // fresh user-driven SessionStart events when no subagent/wrapper/handoff
200	    // gate fires. The predicate is pure over (EnvSnapshot, SourceInput, bool)
201	    // — env captures happen ONCE here at run() entry, and the predicate body
202	    // itself does no I/O. On reject (any gate fires), this block has ZERO
203	    // stdout AND ZERO stderr side effects (silent-rejection contract /
204	    // AUTO-04 / Pattern S7). On accept, falls through to run_with_input so
205	    // the standard resume logic still fires — the AUTO-03 gate
206	    // `parent_pid_has_active_perch=false` ensures resume's <owl-active-perch>
207	    // emission cannot collide with the auto-pick block we just wrote.
208	    let env = AutoPickEnvSnapshot::capture();
209	    let src = parse_auto_pick_source_input(&input);
210	    let ppid_has_perch =
211	        crate::common::hook_output::find_perch_by_parent_pid().is_some();
212	    if should_emit_auto_pick(&env, &src, ppid_has_perch) {
213	        let pick_json = serde_json::to_string(&crate::live::pick_spec::build_spec(None))
214	            .unwrap_or_default();
215	        emit_auto_pick(&pick_json);
216	    }
217
218	    // Phase 35 Plan 10 (SC1 gap-closure): the old Step 6.6 sentinel-consume +
219	    // passive emit_sync_prompt() block was REMOVED. The sync offer no longer
220	    // rides a `.sync-prompt-due` sentinel into a passive SessionStart envelope
221	    // (which Claude ignored — never rendered). It now delivers ACTIVELY as an
222	    // owl message: on the /clear|/compact boundary (see the is_live branch of
223	    // inject_reorientation_if_needed below) and on $LIVE start/revive (see
224	    // src/live/start.rs). Both routes call
225	    // crate::owl::sync_prompt::deliver_sync_prompt_to_perch.
226
227	    // Step 7: Delegate to session-resume with pre-read input
228	    super::resume::run_with_input(&input);
229	}
230
231	/// Phase 29 D3: snapshot `info.json.session_id` BEFORE
232	/// `refresh_info_json_session_id` overwrites it. Returns the prior session UUID
233	/// (Claude UUID shape) so the SessionStart hook can dispatch a
234	/// `FIRE_ECHO_COMMUNE_NOW` control message carrying `prior_session={uuid}`.
235	///
236	/// Returns `None` when:
237	///   - input is empty,
238	///   - stdin JSON is malformed or lacks `session_id`,
239	///   - the perch cannot be resolved (no matching self-perch on this machine),
240	///   - `info.json` is missing / malformed,
241	///   - `info.json.session_id` is empty.
242	///
243	/// ## Why we resolve by parent_pid only (not `find_perch_by_session`)
244	///
245	/// `find_perch_by_session` has a self-heal side effect on the parent_pid
246	/// fallback path: it calls `patch_session_id(owl_id, new_session_id)` which
247	/// OVERWRITES `info.json.session_id` to the NEW UUID. By the time we then
248	/// `read_to_string(info_file(&owl_id))`, the prior UUID is gone — we'd
249	/// return the new UUID, defeating the snapshot's purpose. (Rule 1 bug fix
250	/// vs the suggested resolver chain in 29-02-PLAN action step (4).)
251	///
252	/// Resolving directly by parent_pid avoids the patch entirely. The
253	/// `find_perch_by_session` happy path (primary session_id match) would also
254	/// be safe — but on `/clear` / `/compact` boundaries the NEW session_id will
255	/// NEVER match info.json (info.json still has the prior session at this
256	/// point — that's the whole reason we're snapshotting), so the primary path
257	/// never fires; we'd always hit the fallback, which patches. Using
258	/// `find_perch_by_parent_pid` directly is correct and cheaper.
259	fn snapshot_prior_session_id(input: &str) -> Option<String> {
260	    use crate::common::hook_output;
261
262	    if input.is_empty() {
263	        return None;
264	    }
265
266	    #[derive(serde::Deserialize, Default)]
267	    struct SessionInput {
268	        #[serde(default)]
269	        session_id: Option<String>,
270	    }
271
272	    let parsed: SessionInput = serde_json::from_str(input).ok()?;
273	    // Gate on stdin session_id presence/non-empty even though we don't use the
274	    // value for resolution — mirrors `refresh_info_json_session_id` semantics
275	    // (the snapshot is only meaningful when we have a NEW session_id to thread
276	    // through the dispatch). Plan tests E/F assert this gate.
277	    let _new_session_id = parsed.session_id.filter(|s| !s.is_empty())?;
278
279	    let owl_id = hook_output::find_perch_by_parent_pid()?;
280
281	    let info_path = crate::common::owlery::info_file(&owl_id);
282	    let content = std::fs::read_to_string(&info_path).ok()?;
283	    let info: crate::common::types::InfoJson = serde_json::from_str(&content).ok()?;
284	    if info.session_id.is_empty() {
285	        None
286	    } else {
287	        Some(info.session_id)
288	    }
289	}
290
291	/// Parse source from stdin JSON. If "clear" or "compact", inject re-orientation
292	/// context and return true. Otherwise return false (caller should delegate to resume).
293	///
294	/// `prior_session_id` is the Phase 29 snapshot of `info.json.session_id` taken
295	/// BEFORE `refresh_info_json_session_id` overwrites it. When present and the
296	/// resolved perch is Live, Task 2 dispatches a `FIRE_ECHO_COMMUNE_NOW` control
297	/// message carrying this UUID.
298	fn inject_reorientation_if_needed(input: &str, prior_session_id: Option<&str>) -> bool {
299	    if input.is_empty() {
300	        return false;
301	    }
302
303	    #[derive(serde::Deserialize, Default)]
304	    struct SourceInput {
305	        #[serde(default)]
306	        source: Option<String>,
307	        #[serde(default)]
308	        agent_type: Option<String>,
309	    }
310
311	    let parsed: SourceInput = match serde_json::from_str(input) {
312	        Ok(p) => p,
313	        Err(_) => return false,
314	    };
315
316	    // Skip agent-mode sessions (psyche wrapper, etc.)
317	    if parsed.agent_type.is_some() {
318	        return false;
319	    }
320	    // Skip team members
321	    if std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok() {
322	        return false;
323	    }
324
325	    // The source token is borrowed by the Phase 29 dispatch below; bind it
326	    // (statically "clear" or "compact" from the matched arm).
327	    let source_token: &str = match parsed.source.as_deref() {
328	        Some("clear") => "clear",
329	        Some("compact") => "compact",
330	        _ => return false,
331	    };
332
333	    // Resolve agent_id from OWL_SESSION_ID or parent_pid
334	    let owl_id = resolve_agent_id_for_reorientation();
335	    match owl_id {
336	        Some(id) => {
337	            // Phase 28 (SS-PSYDL-01): resolve is_live from info.json for the
338	            // resolved perch. Failure-mode chain collapses any read/parse
339	            // error to is_live=false — non-live perches still get the
340	            // existing <spacetime-reorientation> block (graceful degradation,
341	            // Pitfall 4).
342	            let is_live = std::fs::read_to_string(crate::common::owlery::info_file(&id))
343	                .ok()
344	                .and_then(|c| serde_json::from_str::<crate::common::types::InfoJson>(&c).ok())
345	                .map(|info| info.state == crate::common::types::PerchState::Live)
346	                .unwrap_or(false);
347	            super::resume::inject_reorientation(&id, is_live);
348
349	            // Phase 29 dispatch (D7): when is_live AND a prior session UUID
350	            // was snapshotted, deliver a FIRE_ECHO_COMMUNE_NOW control
351	            // message to `{id}-psyche`. Fire-and-return (D5): catch_unwind
352	            // absorbs TCP/spool panics; no synchronous block. UUID validation
353	            // happens inside dispatch_fire_echo_commune (T-29-05 mitigation).
354	            if is_live {
355	                if let Some(prior_uuid) = prior_session_id {
356	                    dispatch_fire_echo_commune(&id, prior_uuid, source_token);
357	                }
358
359	                // Phase 35 Plan 10 (SC1 gap-closure): clear/compact boundary
360	                // sync-prompt trigger. ACTIVE delivery of the
361	                // <spt-psyche-sync-prompt> AskUserQuestion to this live agent's
362	                // own perch. Gated on is_live (locked decision 1: live-agent
363	                // users only — non-live perches never receive the prompt). The
364	                // predicate inside deliver_sync_prompt_to_perch self-gates on
365	                // sync.state + gh presence, so no extra gating is needed here.
366	                let _ = crate::owl::sync_prompt::deliver_sync_prompt_to_perch(&id);
367	            }
368
369	            true
370	        }
371	        None => false, // No perch found -- skip reorientation
372	    }
373	}
374
375	/// Phase 29 Plan 02 Task 2: validate `prior_uuid` against the Claude UUID
376	/// shape (`8-4-4-4-12` lowercase/uppercase hex) and build the
377	/// `FIRE_ECHO_COMMUNE_NOW` control-message body. Returns `None` when the
378	/// UUID is malformed (T-29-05 mitigation: emit-site validation; Plan 03's
379	/// wrapper handler re-validates before subprocess spawn — defense in depth).
380	///
381	/// Wire format (consumed verbatim by `parse_fire_echo_commune_now` in
382	/// `src/live/wrapper/mod.rs`):
383	///
384	/// `FIRE_ECHO_COMMUNE_NOW prior_session={uuid} forward_to_self=true source={token}`
385	///
386	/// The literal prefix `FIRE_ECHO_COMMUNE_NOW prior_session=` lives ONLY in
387	/// `src/live/wrapper/mod.rs::FIRE_ECHO_COMMUNE_NOW_PREFIX` (Plan 01 Task 3).
388	/// This function imports the constant rather than inlining the literal so
389	/// emit-side and parse-side cannot drift.
390	fn build_fire_echo_commune_body(prior_uuid: &str, source_token: &str) -> Option<String> {
391	    if !is_claude_uuid_shape(prior_uuid) {
392	        return None;
393	    }
394	    Some(format!(
395	        "{}{} forward_to_self=true source={}",
396	        crate::live::wrapper::FIRE_ECHO_COMMUNE_NOW_PREFIX,
397	        prior_uuid,
398	        source_token
399	    ))
400	}
401
402	/// Hand-rolled Claude UUID shape check: `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-
403	/// [0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`. Avoids adding a `regex`
404	/// dependency for a single validation site (RESEARCH §"V5 Input Validation"
405	/// preferred this over a new crate).
406	fn is_claude_uuid_shape(s: &str) -> bool {
407	    // Required total length = 8+1+4+1+4+1+4+1+12 = 36.
408	    if s.len() != 36 {
409	        return false;
410	    }
411	    let bytes = s.as_bytes();
412	    // Hyphen positions (0-indexed): 8, 13, 18, 23.
413	    if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
414	        return false;
415	    }
416	    // Every other byte must be ASCII hex.
417	    for (i, &b) in bytes.iter().enumerate() {
418	        if i == 8 || i == 13 || i == 18 || i == 23 {
419	            continue;
420	        }
421	        if !b.is_ascii_hexdigit() {
422	            return false;
423	        }
424	    }
425	    true
426	}
427
428	/// Phase 29 Plan 02 Task 2: dispatch the FIRE_ECHO_COMMUNE_NOW control
429	/// message to `{self_id}-psyche` via `send::deliver_body`. UUID validation
430	/// happens inside `build_fire_echo_commune_body`; on validation failure
431	/// emits a single stderr log and skips delivery. Delivery is wrapped in
432	/// `std::panic::catch_unwind` so a TCP/spool panic cannot abort the
433	/// SessionStart hook (Pattern S7).
434	fn dispatch_fire_echo_commune(self_id: &str, prior_uuid: &str, source_token: &str) {
435	    let body = match build_fire_echo_commune_body(prior_uuid, source_token) {
436	        Some(b) => b,
437	        None => {
438	            eprintln!("[FIRE-EC] malformed prior_session, skipping");
439	            return;
440	        }
441	    };
442	    let psyche_target = format!("{}-psyche", self_id);
443	    let from = self_id.to_string();
444	    let _ = std::panic::catch_unwind(move || {
445	        crate::owl::send::deliver_body(&psyche_target, &from, &body);
446	    });
447	}
448
449	/// Resolve agent_id for re-orientation: try OWL_SESSION_ID env var, then parent_pid.
450	fn resolve_agent_id_for_reorientation() -> Option<String> {
451	    use crate::common::hook_output;
452
453	    if let Ok(sid) = std::env::var("OWL_SESSION_ID") {
454	        if !sid.is_empty() {
455	            if let Some(id) = hook_output::find_perch_by_session(&sid) {
456	                return Some(id);
457	            }
458	        }
459	    }
460	    hook_output::find_perch_by_parent_pid()
461	}
462
463	/// Extract session_id from hook stdin JSON and append OWL_SESSION_ID to CLAUDE_ENV_FILE.
464	fn write_session_id(input: &str) {
465	    if input.is_empty() {
466	        return;
467	    }
468
469	    // Parse session_id from the input
470	    #[derive(serde::Deserialize, Default)]
471	    struct SessionInput {
472	        #[serde(default)]
473	        session_id: Option<String>,
474	    }
475
476	    let parsed: SessionInput = match serde_json::from_str(input) {
477	        Ok(p) => p,
478	        Err(_) => return,
479	    };
480
481	    let session_id = match parsed.session_id {
482	        Some(sid) if !sid.is_empty() => sid,
483	        _ => return,
484	    };
485
486	    // Write to CLAUDE_ENV_FILE if available
487	    if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
488	        if !env_file.is_empty() {
489	            if let Ok(mut f) = std::fs::OpenOptions::new()
490	                .create(true)
491	                .append(true)
492	                .open(&env_file)
493	            {
494	                let _ = writeln!(f, "export OWL_SESSION_ID=\"{}\"", session_id);
495	            }
496	        }
497	    }
498	}
499
500	/// Write OWL/LIVE env vars to CLAUDE_ENV_FILE. Returns true if successful.
501	/// In plugin mode, CLAUDE_ENV_FILE is guaranteed by the plugin system.
502	fn write_env_vars() -> bool {
503	    let bin_path = match std::env::current_exe() {
504	        Ok(p) => p.display().to_string().replace('\\', "/"),
505	        Err(_) => return false,
506	    };
507
508	    // Write to CLAUDE_ENV_FILE (provided by plugin system)
509	    if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
510	        if !env_file.is_empty() {
511	            if let Ok(mut f) = std::fs::OpenOptions::new()
512	                .create(true)
513	                .append(true)
514	                .open(&env_file)
515	            {
516	                let _ = writeln!(f, "export OWL=\"{}\"", bin_path);
517	                let _ = writeln!(f, "export LIVE=\"{} live\"", bin_path);
518	                return true;
519	            }
520	        }
521	    }
522
523	    false
524	}
525
526	/// Extracts the value of the *last* `export OWL="..."` line from env-file contents.
527	/// Returns None if no such line is present.
528	fn parse_owl_from_env_file(contents: &str) -> Option<String> {
529	    let mut result: Option<String> = None;
530	    for line in contents.lines() {
531	        if let Some(rest) = line.strip_prefix("export OWL=\"") {
532	            if let Some(end) = rest.find('"') {
533	                result = Some(rest[..end].to_string());
534	            }
535	        }
536	    }
537	    result
538	}
539
540	/// Sync OWL/LIVE env vars from CLAUDE_ENV_FILE into ~/.claude/settings.json under the env key.
541	/// Only writes when drift is detected (different from post-trampoline paths).
542	/// Gracefully handles missing/malformed settings.json and missing home directory.
543	fn sync_settings_json() {
544	    // Step 1: Resolve OWL path from CLAUDE_ENV_FILE (authoritative post-trampoline)
545	    let env_file = match std::env::var("CLAUDE_ENV_FILE") {
546	        Ok(v) if !v.is_empty() => v,
547	        _ => return,
548	    };
549	    let contents = match std::fs::read_to_string(&env_file) {
550	        Ok(c) => c,
551	        Err(_) => return,
552	    };
553	    let owl_path = match parse_owl_from_env_file(&contents) {
554	        Some(p) => p,
555	        None => return,
556	    };
557	    let expected_owl = owl_path.clone();
558	    let expected_live = format!("{} live", owl_path);
559
560	    // Step 2: Resolve ~/.claude/settings.json
561	    let home = std::env::var("USERPROFILE")
562	        .or_else(|_| std::env::var("HOME"))
563	        .ok();
564	    let home = match home {
565	        Some(h) if !h.is_empty() => h,
566	        _ => {
567	            eprintln!("[owl] settings.json sync: no home dir");
568	            return;
569	        }
570	    };
571	    let settings_path = std::path::PathBuf::from(home)
572	        .join(".claude")
573	        .join("settings.json");
574
575	    // Step 3: Read + parse, bail gracefully on any issue
576	    let raw = match std::fs::read_to_string(&settings_path) {
577	        Ok(c) => c,
578	        Err(_) => {
579	            eprintln!("[owl] settings.json sync: file missing, skipping");
580	            return;
581	        }
582	    };
583	    let parsed: serde_json::Value = match serde_json::from_str(&raw) {
584	        Ok(v) => v,
585	        Err(_) => {
586	            eprintln!("[owl] settings.json sync: malformed JSON, skipping");
587	            return;
588	        }
589	    };
590	    if !parsed.is_object() {
591	        eprintln!("[owl] settings.json sync: malformed JSON, skipping");
592	        return;
593	    }
594
595	    // Step 4: Drift-check via pure helper
596	    let updated = match compute_settings_update(&parsed, &expected_owl, &expected_live) {
597	        Some(u) => u,
598	        None => return, // silent no-op
599	    };
600
601	    // Step 5: Write back (pretty-printed, direct write — atomic rename not required)
602	    let serialized = match serde_json::to_string_pretty(&updated) {
603	        Ok(s) => s,
604	        Err(e) => {
605	            eprintln!("[owl] settings.json sync: serialize failed: {}", e);
606	            return;
607	        }
608	    };
609	    if let Err(e) = std::fs::write(&settings_path, serialized) {
610	        eprintln!("[owl] settings.json sync: write failed: {}", e);
611	        return;
612	    }
613	    eprintln!(
614	        "[owl] synced settings.json env: OWL={} LIVE={}",
615	        expected_owl, expected_live
616	    );
617	}
618
619	/// Parse a version string like "1.2.3" into (major, minor, patch).
620	fn parse_version(s: &str) -> Option<(u32, u32, u32)> {
621	    let parts: Vec<&str> = s.split('.').collect();
622	    if parts.len() != 3 {
623	        return None;
624	    }
625	    let major = parts[0].parse::<u32>().ok()?;
626	    let minor = parts[1].parse::<u32>().ok()?;
627	    let patch = parts[2].parse::<u32>().ok()?;
628	    Some((major, minor, patch))
629	}
630
631	/// Pure drift-check function: returns Some(updated_value) when settings.json must be rewritten,
632	/// None when OWL/LIVE already match.
633	/// - If root is not an object -> None (caller must log and bail separately).
634	/// - If env key is missing or not an object -> creates it and returns Some(...).
635	/// - If env.OWL or env.LIVE differs from expected -> returns Some(...).
636	/// - If both match -> None.
637	fn compute_settings_update(
638	    current: &serde_json::Value,
639	    expected_owl: &str,
640	    expected_live: &str,
641	) -> Option<serde_json::Value> {
642	    let obj = current.as_object()?; // non-object root -> None
643	    let env_matches = obj
644	        .get("env")
645	        .and_then(|v| v.as_object())
646	        .map(|env| {
647	            env.get("OWL").and_then(|v| v.as_str()) == Some(expected_owl)
648	                && env.get("LIVE").and_then(|v| v.as_str()) == Some(expected_live)
649	        })
650	        .unwrap_or(false);
651	    if env_matches {
652	        return None;
653	    }
654	    // Clone the full object to preserve all sibling keys and their insertion order.
655	    let mut new_obj = obj.clone();
656	    // Rebuild env as a fresh object so non-object or missing env is handled uniformly.
657	    let mut env_obj = serde_json::Map::new();
658	    env_obj.insert("OWL".to_string(), serde_json::Value::String(expected_owl.to_string()));
659	    env_obj.insert("LIVE".to_string(), serde_json::Value::String(expected_live.to_string()));
660	    new_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
661	    Some(serde_json::Value::Object(new_obj))
662	}
663
664	/// Scan sibling version directories in the plugin cache for a newer owl.exe.
665	/// If found, rewrite CLAUDE_ENV_FILE to point to the newest version.
666	fn version_trampoline() {
667	    // Get current exe path, normalize to forward slashes
668	    let current_exe = match std::env::current_exe() {
669	        Ok(p) => p.display().to_string().replace('\\', "/"),
670	        Err(_) => return,
671	    };
672
673	    // Extract version from path: .../spt/<version>/owl.exe
674	    // We need at least 2 path components above the binary
675	    let parts: Vec<&str> = current_exe.rsplitn(3, '/').collect();
676	    if parts.len() < 3 {
677	        return; // Not enough path components
678	    }
679	    // parts[0] = "owl.exe", parts[1] = version dir, parts[2] = everything before
680	    let exe_name = parts[0];
681	    let current_version_str = parts[1];
682	    let spt_dir = parts[2]; // grandparent path (the spt/ directory)
683
684	    let current_version = match parse_version(current_version_str) {
685	        Some(v) => v,
686	        None => return, // Not running from a versioned plugin cache dir (e.g., cargo target/)
687	    };
688
689	    // Scan sibling version directories
690	    let spt_path = std::path::PathBuf::from(spt_dir);
691	    let entries = match std::fs::read_dir(&spt_path) {
692	        Ok(e) => e,
693	        Err(_) => return,
694	    };
695
696	    let mut best_version = current_version;
697	    let mut best_dir: Option<String> = None;
698
699	    for entry in entries.flatten() {
700	        let dir_name = entry.file_name().to_string_lossy().to_string();
701	        if let Some(ver) = parse_version(&dir_name) {
702	            let dir_path = spt_path.join(&dir_name);
703	            // Skip orphaned cache directories (Claude Code marks these for GC)
704	            if dir_path.join(".orphaned_at").exists() {
705	                continue;
706	            }
707	            // Check if owl.exe exists in this version dir
708	            let candidate = dir_path.join(exe_name);
709	            if ver > best_version && candidate.exists() {
710	                best_version = ver;
711	                best_dir = Some(dir_name);
712	            }
713	        }
714	    }
715
716	    // If a newer version was found, rewrite env vars to point to it
717	    if let Some(dir_name) = best_dir {
718	        let new_bin = format!("{}/{}/{}", spt_dir, dir_name, exe_name)
719	            .replace('\\', "/");
720
721	        // Update CLAUDE_ENV_FILE if available (per-session env vars)
722	        if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
723	            if !env_file.is_empty() {
724	                if let Ok(content) = std::fs::read_to_string(&env_file) {
725	                    let mut new_lines: Vec<String> = Vec::new();
726	                    for line in content.lines() {
727	                        if line.starts_with("export OWL=") {
728	                            new_lines.push(format!("export OWL=\"{}\"", new_bin));
729	                        } else if line.starts_with("export LIVE=") {
730	                            new_lines.push(format!("export LIVE=\"{} live\"", new_bin));
731	                        } else {
732	                            new_lines.push(line.to_string());
733	                        }
734	                    }
735	                    let _ = std::fs::write(&env_file, new_lines.join("\n") + "\n");
736	                }
737	            }
738	        }
739
740	        eprintln!(
741	            "[owl] version trampoline: upgrading from {} to {}",
742	            current_version_str, dir_name
743	        );
744
745	        // Backfill: copy newer binary AND hooks.json into the current (old) version dir.
746	        // Claude Code may load hooks from the old version dir for this session.
747	        // Without binary backfill, hook commands added in newer versions fail with
748	        // "unrecognized subcommand". Without hooks.json backfill, new hook events
749	        // (e.g. Stop) are never registered and never fire.
750	        let new_dir = spt_path.join(&dir_name);
751	        let old_dir = spt_path.join(current_version_str);
752
753	        // Backfill binary
754	        let new_bin_path = new_dir.join(exe_name);
755	        if new_bin_path.exists() {
756	            if let Err(e) = std::fs::copy(&new_bin_path, old_dir.join(exe_name)) {
757	                eprintln!("[owl] version trampoline: backfill binary failed: {}", e);
758	            }
759	        }
760
761	        // Backfill hooks.json (new hook events won't fire without this)
762	        let new_hooks = new_dir.join("hooks").join("hooks.json");
763	        let old_hooks = old_dir.join("hooks").join("hooks.json");
764	        if new_hooks.exists() {
765	            if let Some(parent) = old_hooks.parent() {
766	                let _ = std::fs::create_dir_all(parent);
767	            }
768	            if let Err(e) = std::fs::copy(&new_hooks, &old_hooks) {
769	                eprintln!("[owl] version trampoline: backfill hooks.json failed: {}", e);
770	            }
771	        }
772	    }
773	}
774
775	/// Phase 18.8.1 REQ-REFRESH-01: rewrite `owlery/{owl_id}/info.json.session_id`
776	/// to the stdin-provided session_id on every SessionStart.
777	///
778	/// Closes `.planning/debug/doyle-listener-info-json-stale-session-id.md` —
779	/// previously info.json was written ONCE at listener startup and never
780	/// refreshed when Claude's foreground session rotated mid-run; echo-commune
781	/// then diffed the wrong jsonl UUID, producing empty [COMMUNE] blocks.
782	///
783	/// Perch identity resolution uses the existing `find_perch_by_session` →
784	/// `find_perch_by_parent_pid` fallback chain (same pattern as
785	/// `resolve_agent_id_for_reorientation`). Both helpers already filter out
786	/// psyche/spine/touch perches via `list_self_perches` (hook_output.rs) —
787	/// no risk of overwriting psyche/spine/touch session_id fields with
788	/// Claude-UUID-shaped values.
789	///
790	/// Race with listener startup (Pitfall 6): SessionStart fires BEFORE any
791	/// listener write on a fresh `/live start`, so ordering is
792	/// hook-then-listener in the rotation+restart case. If listener wins the
793	/// race, info.json is already current — acceptable. Best-effort, no locking.
794	///
795	/// Visibility: `pub` so tests/plugin_session_start_refresh.rs (a separate
796	/// integration-test crate) can drive the helper directly. Matches the
797	/// Phase 18.7.1 precedent of promoting `poll`/`new_alarm` from `pub(crate)`
798	/// to `pub` for integration-test access. REQ-REFRESH-01 Task 3 prerequisite.
799	pub fn refresh_info_json_session_id(input: &str) {
800	    use crate::common::hook_output;
801
802	    if input.is_empty() {
803	        return;
804	    }
805
806	    #[derive(serde::Deserialize, Default)]
807	    struct SessionInput {
808	        #[serde(default)]
809	        session_id: Option<String>,
810	    }
811	    let parsed: SessionInput = match serde_json::from_str(input) {
812	        Ok(p) => p,
813	        Err(_) => return,
814	    };
815	    let session_id = match parsed.session_id {
816	        Some(sid) if !sid.is_empty() => sid,
817	        _ => return,
818	    };
819
820	    // Resolve owning perch — session_id first (fast-path cache in
821	    // find_perch_by_session), parent_pid fallback for the
822	    // first-fire-after-rotation case where info.json.session_id is still
823	    // stale from the previous session.
824	    let owl_id = hook_output::find_perch_by_session(&session_id)
825	        .or_else(hook_output::find_perch_by_parent_pid);
826
827	    let Some(id) = owl_id else { return }; // No self-perch on this machine — nothing to refresh.
828
829	    // Patch info.json — promoted to pub(crate) in Plan 03 Task 1.
830	    hook_output::patch_session_id(&id, &session_id);
831	}
832
833	#[cfg(test)]
834	mod tests {
835	    use super::*;
836
837	    #[test]
838	    fn match_returns_none() {
839	        let current = serde_json::json!({
840	            "env": { "OWL": "X", "LIVE": "X live" }
841	        });
842	        assert!(compute_settings_update(&current, "X", "X live").is_none());
843	    }
844
845	    #[test]
846	    fn drift_returns_updated_value_preserving_sibling_order() {
847	        let current = serde_json::json!({
848	            "permissions": { "allow": ["A", "B"] },
849	            "env": { "OWL": "OLD", "LIVE": "OLD live" }
850	        });
851	        let updated = compute_settings_update(&current, "NEW", "NEW live").unwrap();
852	        let obj = updated.as_object().unwrap();
853	        let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
854	        assert_eq!(keys, vec!["permissions", "env"]);
855	        assert_eq!(obj["env"]["OWL"], "NEW");
856	        assert_eq!(obj["env"]["LIVE"], "NEW live");
857	    }
858
859	    #[test]
860	    fn missing_env_object_is_created_preserving_siblings() {
861	        let current = serde_json::json!({
862	            "permissions": { "allow": ["A"] }
863	        });
864	        let updated = compute_settings_update(&current, "X", "X live").unwrap();
865	        let obj = updated.as_object().unwrap();
866	        let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
867	        assert_eq!(keys, vec!["permissions", "env"]);
868	        assert_eq!(obj["env"]["OWL"], "X");
869	        assert_eq!(obj["env"]["LIVE"], "X live");
870	    }
871
872	    #[test]
873	    fn non_object_env_is_replaced() {
874	        let current = serde_json::json!({
875	            "env": "oops"
876	        });
877	        let updated = compute_settings_update(&current, "X", "X live").unwrap();
878	        let env = updated.as_object().unwrap().get("env").unwrap();
879	        assert_eq!(env["OWL"], "X");
880	        assert_eq!(env["LIVE"], "X live");
881	        assert!(env.is_object());
882	    }
883
884	    #[test]
885	    fn non_object_root_returns_none() {
886	        let arr = serde_json::json!([1, 2, 3]);
887	        assert!(compute_settings_update(&arr, "X", "X live").is_none());
888	        let s = serde_json::json!("string");
889	        assert!(compute_settings_update(&s, "X", "X live").is_none());
890	    }
891
892	    #[test]
893	    fn partial_drift_returns_some() {
894	        // OWL matches, LIVE missing -> Some with both set
895	        let current = serde_json::json!({
896	            "env": { "OWL": "X" }
897	        });
898	        let updated = compute_settings_update(&current, "X", "X live").unwrap();
899	        let env = updated.as_object().unwrap().get("env").unwrap();
900	        assert_eq!(env["OWL"], "X");
901	        assert_eq!(env["LIVE"], "X live");
902	    }
903
904	    #[test]
905	    fn parse_owl_returns_last_export_line() {
906	        let input = "export OWL=\"/old/path/owl.exe\"\nexport LIVE=\"/old/path/owl.exe live\"\nexport OWL=\"/new/path/owl.exe\"\n";
907	        assert_eq!(parse_owl_from_env_file(input).as_deref(), Some("/new/path/owl.exe"));
908	    }
909
910	    #[test]
911	    fn parse_owl_returns_none_when_absent() {
912	        let input = "export OWL_SESSION_ID=\"abc\"\n";
913	        assert_eq!(parse_owl_from_env_file(input), None);
914	    }
915	}
916
917	#[cfg(test)]
918	mod snapshot_tests {
919	    //! Phase 29 Plan 02 Task 1: snapshot_prior_session_id helper tests.
920	    //!
921	    //! ENV_LOCK serializes SPT_HOME mutation across all tests in this module.
922	    //! Tests A-F exercise the helper directly; Test G is a static source-order
923	    //! assertion via include_str byte-index check.
924
925	    use super::*;
926	    use crate::common::types::{InfoJson, PerchState};
927	    use std::sync::Mutex;
928
929	    static ENV_LOCK: Mutex<()> = Mutex::new(());
930
931	    /// Snapshot/restore SPT_HOME so tests do not leak into siblings.
932	    struct EnvSnapshot {
933	        spt_home: Option<String>,
934	    }
935	    impl EnvSnapshot {
936	        fn capture() -> Self {
937	            Self {
938	                spt_home: std::env::var("SPT_HOME").ok(),
939	            }
940	        }
941	    }
942	    impl Drop for EnvSnapshot {
943	        fn drop(&mut self) {
944	            match &self.spt_home {
945	                Some(v) => std::env::set_var("SPT_HOME", v),
946	                None => std::env::remove_var("SPT_HOME"),
947	            }
948	        }
949	    }
950
951	    /// Build an info.json under SPT_HOME/owlery/<id>/ with the given
952	    /// `session_id` and `parent_pid` (so `find_perch_by_parent_pid` can match
953	    /// it) and create a ready file (so list_self_perches counts it online).
954	    fn write_test_perch(id: &str, session_id: &str, parent_pid: Option<u32>) {
955	        let perch = crate::common::owlery::perch_dir(id);
956	        std::fs::create_dir_all(&perch).unwrap();
957	        let info = InfoJson::new(
958	            id,
959	            "listen",
960	            std::process::id(),
961	            session_id,
962	            PerchState::Live,
963	            parent_pid,
964	            None,
965	        );
966	        std::fs::write(
967	            crate::common::owlery::info_file(id),
968	            serde_json::to_string(&info).unwrap(),
969	        )
970	        .unwrap();
971	        std::fs::write(crate::common::owlery::ready_file(id), "").unwrap();
972	    }
973
974	    #[test]
975	    fn snapshot_returns_some_when_info_json_has_session_id() {
976	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
977	        let _snap = EnvSnapshot::capture();
978	        let tmp = tempfile::tempdir().unwrap();
979	        std::env::set_var("SPT_HOME", tmp.path());
980	        crate::common::hook_output::clear_session_cache();
981
982	        // Production flow on `/clear`: info.json (written by listener startup
983	        // or a prior refresh) carries the PRIOR session UUID; stdin's session_id
984	        // is the new one Claude rotated to. Snapshot must return the prior.
985	        // Resolver uses `find_perch_by_parent_pid` (no patch side-effect),
986	        // so the test perch must carry the current process's parent_pid.
987	        let ppid = crate::common::types::get_parent_pid()
988	            .expect("test process should have a resolvable parent_pid");
989	        write_test_perch("alpha", "prior-uuid", Some(ppid));
990
991	        let input = r#"{"session_id":"new-uuid","source":"clear"}"#;
992
993	        let got = snapshot_prior_session_id(input);
994	        assert_eq!(got.as_deref(), Some("prior-uuid"));
995	    }
996
997	    #[test]
998	    fn snapshot_returns_none_when_info_json_session_id_empty() {
999	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1000	        let _snap = EnvSnapshot::capture();
1001	        let tmp = tempfile::tempdir().unwrap();
1002	        std::env::set_var("SPT_HOME", tmp.path());
1003	        crate::common::hook_output::clear_session_cache();
1004
1005	        let ppid = crate::common::types::get_parent_pid()
1006	            .expect("test process should have a resolvable parent_pid");
1007	        write_test_perch("beta", "", Some(ppid));
1008	        let input = r#"{"session_id":"new-uuid"}"#;
1009
1010	        let got = snapshot_prior_session_id(input);
1011	        assert!(got.is_none(), "empty info.json.session_id must return None, got {:?}", got);
1012	    }
1013
1014	    #[test]
1015	    fn snapshot_returns_none_when_info_json_missing() {
1016	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1017	        let _snap = EnvSnapshot::capture();
1018	        let tmp = tempfile::tempdir().unwrap();
1019	        std::env::set_var("SPT_HOME", tmp.path());
1020	        crate::common::hook_output::clear_session_cache();
1021
1022	        // No perch on disk -> resolver returns None -> helper short-circuits.
1023	        let input = r#"{"session_id":"new-uuid"}"#;
1024	        let got = snapshot_prior_session_id(input);
1025	        assert!(got.is_none());
1026	    }
1027
1028	    #[test]
1029	    fn snapshot_returns_none_when_input_empty() {
1030	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1031	        let _snap = EnvSnapshot::capture();
1032	        assert!(snapshot_prior_session_id("").is_none());
1033	    }
1034
1035	    #[test]
1036	    fn snapshot_returns_none_when_input_malformed_json() {
1037	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1038	        let _snap = EnvSnapshot::capture();
1039	        assert!(snapshot_prior_session_id("not json").is_none());
1040	    }
1041
1042	    #[test]
1043	    fn snapshot_returns_none_when_input_lacks_session_id() {
1044	        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1045	        let _snap = EnvSnapshot::capture();
1046	        // Valid JSON but no session_id key.
1047	        assert!(snapshot_prior_session_id("{}").is_none());
1048	        // Empty session_id is treated the same as missing.
1049	        assert!(snapshot_prior_session_id(r#"{"session_id":""}"#).is_none());
1050	    }
1051
1052	    /// Test G: source-order assertion via include_str byte-scan.
1053	    /// Pins the non-negotiable ordering (Pitfall 1): snapshot must be called
1054	    /// AFTER write_session_id and BEFORE refresh_info_json_session_id.
1055	    #[test]
1056	    fn snapshot_call_site_lives_between_write_and_refresh() {
1057	        const SRC: &str = include_str!("plugin_session_start.rs");
1058	        let write_idx = SRC
1059	            .find("write_session_id(&input);")
1060	            .expect("write_session_id call must exist in run()");
1061	        let snapshot_idx = SRC
1062	            .find("snapshot_prior_session_id(&input)")
1063	            .expect("snapshot_prior_session_id call must exist in run()");
1064	        let refresh_idx = SRC
1065	            .find("refresh_info_json_session_id(&input);")
1066	            .expect("refresh_info_json_session_id call must exist in run()");
1067	        assert!(
1068	            write_idx < snapshot_idx,
1069	            "snapshot must be called AFTER write_session_id (write={} snapshot={})",
1070	            write_idx,
1071	            snapshot_idx
1072	        );
1073	        assert!(
1074	            snapshot_idx < refresh_idx,
1075	            "snapshot must be called BEFORE refresh_info_json_session_id (snapshot={} refresh={})",
1076	            snapshot_idx,
1077	            refresh_idx
1078	        );
1079	    }
1080	}
1081
1082	#[cfg(test)]
1083	mod dispatch_tests {
1084	    //! Phase 29 Plan 02 Task 2: FIRE_ECHO_COMMUNE_NOW dispatch tests.
1085	    //!
1086	    //! Drives the helper layer (build_fire_echo_commune_body) rather than
1087	    //! the full `inject_reorientation_if_needed` resolver chain — the seam
1088	    //! returns `Option<String>` so panic-free, network-free unit tests can
1089	    //! assert wire-format correctness, UUID validation, and source-order
1090	    //! pinning (Test F).
1091
1092	    use super::*;
1093
1094	    const VALID_UUID: &str = "12345678-1234-1234-1234-123456789012";
1095	    const VALID_UUID_UPPER: &str = "ABCDEF12-3456-7890-ABCD-EF0123456789";
1096
1097	    // ---- Test A: dispatch fires for source=clear ----
1098	    #[test]
1099	    fn build_body_test_a_source_clear_happy_path() {
1100	        let body = build_fire_echo_commune_body(VALID_UUID, "clear")
1101	            .expect("valid UUID + clear must produce a body");
1102	        // Wire format = `FIRE_ECHO_COMMUNE_NOW prior_session={uuid} forward_to_self=true source=clear`
1103	        // The literal prefix is sourced from FIRE_ECHO_COMMUNE_NOW_PREFIX (Plan 01 Task 3).
1104	        let expected = format!(
1105	            "FIRE_ECHO_COMMUNE_NOW prior_session={} forward_to_self=true source=clear",
1106	            VALID_UUID
1107	        );
1108	        assert_eq!(body, expected);
1109	        assert!(body.contains("FIRE_ECHO_COMMUNE_NOW"));
1110	        assert!(body.contains("forward_to_self=true"));
1111	        assert!(body.contains("source=clear"));
1112	    }
1113
1114	    // ---- Test B: dispatch fires for source=compact ----
1115	    #[test]
1116	    fn build_body_test_b_source_compact_happy_path() {
1117	        let body = build_fire_echo_commune_body(VALID_UUID, "compact")
1118	            .expect("valid UUID + compact must produce a body");
1119	        assert!(body.contains("source=compact"));
1120	        assert!(body.contains(VALID_UUID));
1121	        assert!(body.starts_with("FIRE_ECHO_COMMUNE_NOW prior_session="));
1122	    }
1123
1124	    // ---- Test C: no dispatch when prior_uuid is None (caller-side gate) ----
1125	    //
1126	    // `dispatch_fire_echo_commune` is only invoked when prior_session_id is
1127	    // Some(...). At the build-body layer, the matching invariant is "no UUID
1128	    // ever produces an Option<String> at all" — verify by asserting that the
1129	    // empty string fails validation (the closest helper-layer expression).
1130	    #[test]
1131	    fn build_body_test_c_empty_uuid_returns_none() {
1132	        assert!(build_fire_echo_commune_body("", "clear").is_none());
1133	    }
1134
1135	    // ---- Test D: no dispatch when is_live is false ----
1136	    //
1137	    // is_live is a caller-side gate inside `inject_reorientation_if_needed`.
1138	    // Test the inverse contract: even when the UUID + source token are valid,
1139	    // the caller must NOT invoke dispatch when is_live=false. The helper
1140	    // itself cannot enforce that — but assert the body is well-formed so we
1141	    // can prove the caller's gate is the only thing preventing dispatch.
1142	    #[test]
1143	    fn build_body_test_d_is_live_gate_lives_in_caller_not_helper() {
1144	        // Helper happily produces a body — the is_live gate must be at the
1145	        // call site. Verified by source-order test F + the caller invariant
1146	        // that `dispatch_fire_echo_commune` is inside `if is_live { ... }`.
1147	        let body = build_fire_echo_commune_body(VALID_UUID, "clear");
1148	        assert!(body.is_some());
1149	        // Source-order assertion: dispatch call site is inside an `if is_live`
1150	        // gate followed by an inner `if let Some(prior_uuid) = prior_session_id`.
1151	        const SRC: &str = include_str!("plugin_session_start.rs");
1152	        let dispatch_call = SRC
1153	            .find("dispatch_fire_echo_commune(&id, prior_uuid, source_token);")
1154	            .expect("dispatch_fire_echo_commune call site must exist");
1155	        // Scan backwards from dispatch_call for the nearest `if is_live {`
1156	        // line — the gate must immediately precede the call site.
1157	        let region_before = &SRC[..dispatch_call];
1158	        let last_is_live = region_before
1159	            .rfind("if is_live {")
1160	            .expect("dispatch_fire_echo_commune must be inside an `if is_live` block");
1161	        let last_some_prior = region_before
1162	            .rfind("if let Some(prior_uuid) = prior_session_id")
1163	            .expect("dispatch must be inside `if let Some(prior_uuid)` arm");
1164	        assert!(last_is_live < last_some_prior);
1165	        assert!(last_some_prior < dispatch_call);
1166	    }
1167
1168	    // ---- Test E: source ∈ {clear, compact} — fresh path emits nothing ----
1169	    //
1170	    // Enforced by `inject_reorientation_if_needed`'s early-return on
1171	    // non-clear/non-compact source tokens. Verify here that the match arm
1172	    // in the source code only allows "clear" or "compact" through.
1173	    #[test]
1174	    fn build_body_test_e_source_match_arm_only_allows_clear_or_compact() {
1175	        const SRC: &str = include_str!("plugin_session_start.rs");
1176	        // The match arm in inject_reorientation_if_needed binds source_token
1177	        // from `Some("clear") => "clear"` / `Some("compact") => "compact"`
1178	        // and returns false on everything else.
1179	        assert!(
1180	            SRC.contains("Some(\"clear\") => \"clear\","),
1181	            "must accept clear via match arm"
1182	        );
1183	        assert!(
1184	            SRC.contains("Some(\"compact\") => \"compact\","),
1185	            "must accept compact via match arm"
1186	        );
1187	        assert!(
1188	            SRC.contains("_ => return false,"),
1189	            "must early-return on other source tokens"
1190	        );
1191	    }
1192
1193	    // ---- Test F: Phase 28 emit happens BEFORE Phase 29 dispatch ----
1194	    #[test]
1195	    fn build_body_test_f_phase28_emit_before_phase29_dispatch() {
1196	        const SRC: &str = include_str!("plugin_session_start.rs");
1197	        let phase28 = SRC
1198	            .find("super::resume::inject_reorientation(&id, is_live);")
1199	            .expect("Phase 28 inject_reorientation call must exist");
1200	        let phase29 = SRC
1201	            .find("dispatch_fire_echo_commune(&id, prior_uuid, source_token);")
1202	            .expect("Phase 29 dispatch call must exist");
1203	        assert!(
1204	            phase28 < phase29,
1205	            "Phase 28 emit must precede Phase 29 dispatch (phase28={} phase29={})",
1206	            phase28,
1207	            phase29
1208	        );
1209	    }
1210
1211	    // ---- Test G: catch_unwind around delivery (panic-absorption) ----
1212	    #[test]
1213	    fn build_body_test_g_dispatch_wraps_deliver_in_catch_unwind() {
1214	        const SRC: &str = include_str!("plugin_session_start.rs");
1215	        // Confirm the dispatch site uses panic::catch_unwind so a deliver_body
1216	        // panic (TCP/spool failure mode) cannot abort the SessionStart hook.
1217	        let dispatch_fn = SRC
1218	            .find("fn dispatch_fire_echo_commune(")
1219	            .expect("dispatch_fire_echo_commune fn must exist");
1220	        // Scan from the fn definition forward for catch_unwind.
1221	        let region = &SRC[dispatch_fn..];
1222	        assert!(
1223	            region.contains("std::panic::catch_unwind"),
1224	            "dispatch_fire_echo_commune must wrap deliver_body in catch_unwind (Pattern S7)"
1225	        );
1226	        assert!(
1227	            region.contains("crate::owl::send::deliver_body"),
1228	            "dispatch_fire_echo_commune must call send::deliver_body"
1229	        );
1230	    }
1231
1232	    // ---- UUID validation (T-29-05 mitigation) ----
1233	    #[test]
1234	    fn uuid_validation_accepts_lowercase_hex() {
1235	        assert!(is_claude_uuid_shape(VALID_UUID));
1236	    }
1237
1238	    #[test]
1239	    fn uuid_validation_accepts_uppercase_hex() {
1240	        assert!(is_claude_uuid_shape(VALID_UUID_UPPER));
1241	    }
1242
1243	    #[test]
1244	    fn uuid_validation_rejects_wrong_length() {
1245	        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-12345678901")); // 35 chars
1246	        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-1234567890123")); // 37 chars
1247	        assert!(!is_claude_uuid_shape(""));
1248	    }
1249
1250	    #[test]
1251	    fn uuid_validation_rejects_missing_hyphens() {
1252	        assert!(!is_claude_uuid_shape("123456781234-1234-1234-123456789012-")); // hyphen at wrong pos
1253	        assert!(!is_claude_uuid_shape("12345678X1234-1234-1234-123456789012")); // wrong char at pos 8
1254	    }
1255
1256	    #[test]
1257	    fn uuid_validation_rejects_non_hex_chars() {
1258	        assert!(!is_claude_uuid_shape("ZZZZZZZZ-1234-1234-1234-123456789012"));
1259	        assert!(!is_claude_uuid_shape("path/trav-1234-1234-1234-123456789012"));
1260	        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-12345678901G"));
1261	    }
1262
1263	    #[test]
1264	    fn build_body_rejects_malformed_uuid() {
1265	        // T-29-05: emit-site validation. Malformed UUID returns None so
1266	        // dispatch_fire_echo_commune can log + skip without sending.
1267	        assert!(build_fire_echo_commune_body("not-a-uuid", "clear").is_none());
1268	        assert!(build_fire_echo_commune_body("../etc/passwd", "clear").is_none());
1269	        // Path-traversal-shaped 36-char strings still fail the hex check.
1270	        assert!(build_fire_echo_commune_body("00000000-0000-0000-0000-000000000000", "clear").is_some()); // all-zero is still valid hex shape
1271	    }
1272	}
1273
1274	// Phase 35 Plan 10 (SC1 gap-closure): the `sync_prompt_tests` module that
1275	// covered the old passive `should_emit_sync_prompt` + `build_sync_prompt_envelope`
1276	// was REMOVED. The predicate moved to `crate::owl::sync_prompt`; its
1277	// (expanded) truth-table corpus + the active-delivery block-shape tests now
1278	// live there (and the render-path test lives in tests/sync_prompt_render.rs).
1279
1280	#[cfg(test)]
1281	mod auto_pick_predicate_tests {
1282	    //! Phase 33 Plan 03 AUTO-03/04: pure-predicate corpus for
1283	    //! `should_emit_auto_pick`. Drives the helper layer rather than the
1284	    //! `run()` wire so panic-free, network-free unit tests can assert
1285	    //! allow-list gates table-driven (microseconds per case; no
1286	    //! subprocess; no env mutation per Pattern S1).
1287	    //!
1288	    //! Corpus contract (RESEARCH §3c):
1289	    //!   - >= 15 should-fire cases prefixed `fire_f{N}_`
1290	    //!   - >= 17 should-NOT-fire cases prefixed `no_fire_n{N}_`
1291	    //!   - source-order pin via include_str! byte-scan (Pattern S6)
1292	    //!   - silent-rejection contract documenter test (pure-function
1293	    //!     guarantee — no side effects on reject)
1294	    //!
1295	    //! Tests intentionally encode behavioral scenarios with distinct
1296	    //! names even when the predicate-input tuple is identical — a
1297	    //! future regression on a specific scenario surfaces with an
1298	    //! actionable failure message rather than a single opaque failure.
1299
1300	    use super::*;
1301
1302	    fn snap_clean() -> AutoPickEnvSnapshot {
1303	        AutoPickEnvSnapshot {
1304	            handoff_child: false,
1305	            trampoline_guard: false,
1306	            echo_commune: false,
1307	            skip_resume: false,
1308	            team_name: false,
1309	        }

// Plugin-mode SessionStart hook handler.
// 1. Injects $OWL and $LIVE env vars via CLAUDE_ENV_FILE
// 2. Writes OWL_SESSION_ID from Claude stdin to CLAUDE_ENV_FILE
// 3. Version trampoline: scans sibling plugin cache dirs for newer owl.exe,
//    rewrites CLAUDE_ENV_FILE to point to the newest version
// 4. Delegates to session-resume logic (existing resume::run)

use std::io::Write;

// =============================================================================
// Phase 33 Plan 03 AUTO-03/04: <spt-live-auto-pick> emission + allow-list
// =============================================================================
//
// Pure predicate `should_emit_auto_pick` decides whether the SessionStart hook
// should emit a `<spt-live-auto-pick>` block (wrapped in a hookSpecificOutput
// envelope JSON line) on the current SessionStart event. The predicate body
// itself does ZERO env reads — env values are captured up-front into
// `EnvSnapshot::capture()` so the predicate is testable as a pure function
// over (EnvSnapshot, SourceInput, bool) tuples.
//
// Silent-rejection contract (AUTO-04): on reject, the call site has ZERO
// stdout AND ZERO stderr side effects so subagent / wrapper / handoff
// transcripts never see a stray `auto-pick` token.

/// Phase 33 AUTO-03: snapshot of the five named env vars that gate
/// auto-pick emission. Reads happen ONLY inside `capture()` so the
/// predicate body is pure.
pub(crate) struct AutoPickEnvSnapshot {
    pub(crate) handoff_child: bool,         // OWL_HANDOFF_CHILD
    pub(crate) trampoline_guard: bool,      // SPT_TRAMPOLINE_GUARD
    pub(crate) echo_commune: bool,          // OWL_ECHO_COMMUNE
    pub(crate) skip_resume: bool,           // OWL_SKIP_RESUME
    pub(crate) team_name: bool,             // CLAUDE_CODE_TEAM_NAME
}

impl AutoPickEnvSnapshot {
    /// Capture env-var presence into a snapshot. Reads happen once at
    /// `run()` entry; the predicate operates on snapshot values so unit
    /// tests can construct snapshots directly without env mutation.
    pub(crate) fn capture() -> Self {
        Self {
            handoff_child: std::env::var("OWL_HANDOFF_CHILD").is_ok(),
            trampoline_guard: std::env::var("SPT_TRAMPOLINE_GUARD").is_ok(),
            echo_commune: std::env::var("OWL_ECHO_COMMUNE").is_ok(),
            skip_resume: std::env::var("OWL_SKIP_RESUME").is_ok(),
            team_name: std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok(),
        }
    }
}

/// Phase 33 AUTO-03: minimal slice of SessionStart hook stdin JSON that
/// the predicate needs. `source` is the fresh-vs-clear-vs-compact
/// discriminator; `agent_type` distinguishes psyche-wrapper subagent
/// sessions.
#[derive(Default, Clone, Debug)]
pub(crate) struct AutoPickSourceInput {
    pub(crate) source: Option<String>,
    pub(crate) agent_type: Option<String>,
}

/// Parse the auto-pick relevant fields from SessionStart stdin JSON.
/// Returns a default (empty) snapshot on parse error — never panics. A
/// default snapshot will fail the `source == "startup"` gate, so a
/// malformed-input degrades-safe to silent rejection.
pub(crate) fn parse_auto_pick_source_input(input: &str) -> AutoPickSourceInput {
    #[derive(serde::Deserialize, Default)]
    struct Raw {
        #[serde(default)]
        source: Option<String>,
        #[serde(default)]
        agent_type: Option<String>,
    }
    let parsed: Raw = serde_json::from_str(input).unwrap_or_default();
    AutoPickSourceInput {
        source: parsed.source,
        agent_type: parsed.agent_type,
    }
}

/// Pure predicate — returns true iff AUTO-03 should emit
/// `<spt-live-auto-pick>` on this SessionStart event.
///
/// Gates (in order, each returning false on rejection):
/// 1. `src.source` MUST be `Some("startup")` (rejects clear / compact /
///    None / any unknown token).
/// 2. `src.agent_type` MUST be `None` (rejects psyche / haiku / any
///    Claude-Code subagent dispatch).
/// 3. `env.handoff_child` OR `env.trampoline_guard` rejects
///    (mid-handoff / mid-trampoline successor session).
/// 4. `env.echo_commune` rejects (echo-commune recursion guard so a
///    haiku subprocess never sees an auto-pick block in its transcript).
/// 5. `env.skip_resume` OR `env.team_name` rejects (scripted opt-out OR
///    Claude Code team-member session).
/// 6. `parent_pid_has_active_perch == true` rejects (skill already
///    attached — would conflict with `<owl-active-perch>` emission).
///
/// On false, the caller emits NOTHING — no stdout, no stderr (AUTO-04
/// silent-rejection contract).
pub(crate) fn should_emit_auto_pick(
    env: &AutoPickEnvSnapshot,
    src: &AutoPickSourceInput,
    parent_pid_has_active_perch: bool,
) -> bool {
    // Gate 1: source MUST be "startup" (not clear/compact/None/other).
    // Case-sensitive match — `STARTUP` / `Startup` / `""` all reject.
    match src.source.as_deref() {
        Some("startup") => {}
        _ => return false,
    }
    // Gate 2: no agent_type (rejects psyche / haiku wrapper sessions).
    if src.agent_type.is_some() {
        return false;
    }
    // Gate 3: no handoff / trampoline successor (mid-binary-swap).
    if env.handoff_child || env.trampoline_guard {
        return false;
    }
    // Gate 4: no echo-commune recursion (haiku subprocess guard).
    if env.echo_commune {
        return false;
    }
    // Gate 5: no scripted opt-out / no team-member session.
    if env.skip_resume || env.team_name {
        return false;
    }
    // Gate 6: no active perch already attached to this parent_pid.
    // Skill is already running — would conflict with <owl-active-perch>.
    if parent_pid_has_active_perch {
        return false;
    }
    true
}

/// Phase 33 AUTO-03: emit `<spt-live-auto-pick>{pick-spec JSON}</spt-live-auto-pick>`
/// inside a hookSpecificOutput SessionStart envelope JSON line. Pattern S2
/// mirror of `resume.rs::inject_reorientation` at lines 415-424. Single
/// `println!` emission; NO `eprintln!` anywhere (silent-rejection contract
/// per AUTO-04 / Pattern S7).
pub(crate) fn emit_auto_pick(pick_spec_json: &str) {
    let context = format!(
        "<spt-live-auto-pick>\n{}\n</spt-live-auto-pick>",
        pick_spec_json
    );
    let response = serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    });
    let out = serde_json::to_string(&response).unwrap_or_else(|_| {
        r#"{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":""}}"#
            .to_string()
    });
    println!("{}", out);
}

// Phase 35 Plan 10 (SC1 gap-closure): the passive emitter
// (`should_emit_sync_prompt` / `build_sync_prompt_envelope` / `emit_sync_prompt`)
// was REMOVED from this file. The offer now renders via ACTIVE owl-message
// delivery — see `crate::owl::sync_prompt` (predicate relocated there;
// `deliver_sync_prompt_to_perch` is the new entry point invoked from $LIVE
// start/revive and the clear/compact boundary below).

pub fn run() {
    // Step 1: Write env vars to CLAUDE_ENV_FILE
    write_env_vars();

    // Step 2: Read all of stdin once (stdin can only be consumed once)
    let mut input = String::new();
    let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut input);

    // Step 3: Extract session_id from stdin JSON, write OWL_SESSION_ID to env file
    write_session_id(&input);

    // Step 3a (Phase 29 D3): snapshot info.json.session_id BEFORE Phase 18.8.1
    // refresh overwrites it. The snapshot is the PRIOR session UUID; threaded
    // into FIRE_ECHO_COMMUNE_NOW so echo-commune can diff the prior jsonl.
    // ORDERING IS NON-NEGOTIABLE: must run AFTER write_session_id and BEFORE
    // refresh_info_json_session_id (Pitfall 1 in 29-RESEARCH.md).
    let prior_session_id = snapshot_prior_session_id(&input);

    // Step 3b (Phase 18.8.1 REQ-REFRESH-01): refresh owlery/{id}/info.json.session_id
    // so echo-commune diffs the correct jsonl after Claude rotates its session.
    // Closes debug/doyle-listener-info-json-stale-session-id.md.
    refresh_info_json_session_id(&input);

    // Step 4: Version trampoline -- rewrite CLAUDE_ENV_FILE to newest version
    version_trampoline();

    // Step 5: Sync OWL/LIVE into settings.json (mirrors env vars for Windows Bash tool compatibility)
    sync_settings_json();

    // Step 6: Check source field -- clear/compact get re-orientation instead of resume
    if inject_reorientation_if_needed(&input, prior_session_id.as_deref()) {
        return; // Re-orientation injected, skip resume
    }

    // Step 6.5 (Phase 33 Plan 03 AUTO-03): emit <spt-live-auto-pick> on
    // fresh user-driven SessionStart events when no subagent/wrapper/handoff
    // gate fires. The predicate is pure over (EnvSnapshot, SourceInput, bool)
    // — env captures happen ONCE here at run() entry, and the predicate body
    // itself does no I/O. On reject (any gate fires), this block has ZERO
    // stdout AND ZERO stderr side effects (silent-rejection contract /
    // AUTO-04 / Pattern S7). On accept, falls through to run_with_input so
    // the standard resume logic still fires — the AUTO-03 gate
    // `parent_pid_has_active_perch=false` ensures resume's <owl-active-perch>
    // emission cannot collide with the auto-pick block we just wrote.
    let env = AutoPickEnvSnapshot::capture();
    let src = parse_auto_pick_source_input(&input);
    let ppid_has_perch =
        crate::common::hook_output::find_perch_by_parent_pid().is_some();
    if should_emit_auto_pick(&env, &src, ppid_has_perch) {
        let pick_json = serde_json::to_string(&crate::live::pick_spec::build_spec(None))
            .unwrap_or_default();
        emit_auto_pick(&pick_json);
    }

    // Phase 35 Plan 10 (SC1 gap-closure): the old Step 6.6 sentinel-consume +
    // passive emit_sync_prompt() block was REMOVED. The sync offer no longer
    // rides a `.sync-prompt-due` sentinel into a passive SessionStart envelope
    // (which Claude ignored — never rendered). It now delivers ACTIVELY as an
    // owl message: on the /clear|/compact boundary (see the is_live branch of
    // inject_reorientation_if_needed below) and on $LIVE start/revive (see
    // src/live/start.rs). Both routes call
    // crate::owl::sync_prompt::deliver_sync_prompt_to_perch.

    // Step 7: Delegate to session-resume with pre-read input
    super::resume::run_with_input(&input);
}

/// Phase 29 D3: snapshot `info.json.session_id` BEFORE
/// `refresh_info_json_session_id` overwrites it. Returns the prior session UUID
/// (Claude UUID shape) so the SessionStart hook can dispatch a
/// `FIRE_ECHO_COMMUNE_NOW` control message carrying `prior_session={uuid}`.
///
/// Returns `None` when:
///   - input is empty,
///   - stdin JSON is malformed or lacks `session_id`,
///   - the perch cannot be resolved (no matching self-perch on this machine),
///   - `info.json` is missing / malformed,
///   - `info.json.session_id` is empty.
///
/// ## Why we resolve by parent_pid only (not `find_perch_by_session`)
///
/// `find_perch_by_session` has a self-heal side effect on the parent_pid
/// fallback path: it calls `patch_session_id(owl_id, new_session_id)` which
/// OVERWRITES `info.json.session_id` to the NEW UUID. By the time we then
/// `read_to_string(info_file(&owl_id))`, the prior UUID is gone — we'd
/// return the new UUID, defeating the snapshot's purpose. (Rule 1 bug fix
/// vs the suggested resolver chain in 29-02-PLAN action step (4).)
///
/// Resolving directly by parent_pid avoids the patch entirely. The
/// `find_perch_by_session` happy path (primary session_id match) would also
/// be safe — but on `/clear` / `/compact` boundaries the NEW session_id will
/// NEVER match info.json (info.json still has the prior session at this
/// point — that's the whole reason we're snapshotting), so the primary path
/// never fires; we'd always hit the fallback, which patches. Using
/// `find_perch_by_parent_pid` directly is correct and cheaper.
fn snapshot_prior_session_id(input: &str) -> Option<String> {
    use crate::common::hook_output;

    if input.is_empty() {
        return None;
    }

    #[derive(serde::Deserialize, Default)]
    struct SessionInput {
        #[serde(default)]
        session_id: Option<String>,
    }

    let parsed: SessionInput = serde_json::from_str(input).ok()?;
    // Gate on stdin session_id presence/non-empty even though we don't use the
    // value for resolution — mirrors `refresh_info_json_session_id` semantics
    // (the snapshot is only meaningful when we have a NEW session_id to thread
    // through the dispatch). Plan tests E/F assert this gate.
    let _new_session_id = parsed.session_id.filter(|s| !s.is_empty())?;

    let owl_id = hook_output::find_perch_by_parent_pid()?;

    let info_path = crate::common::owlery::info_file(&owl_id);
    let content = std::fs::read_to_string(&info_path).ok()?;
    let info: crate::common::types::InfoJson = serde_json::from_str(&content).ok()?;
    if info.session_id.is_empty() {
        None
    } else {
        Some(info.session_id)
    }
}

/// Parse source from stdin JSON. If "clear" or "compact", inject re-orientation
/// context and return true. Otherwise return false (caller should delegate to resume).
///
/// `prior_session_id` is the Phase 29 snapshot of `info.json.session_id` taken
/// BEFORE `refresh_info_json_session_id` overwrites it. When present and the
/// resolved perch is Live, Task 2 dispatches a `FIRE_ECHO_COMMUNE_NOW` control
/// message carrying this UUID.
fn inject_reorientation_if_needed(input: &str, prior_session_id: Option<&str>) -> bool {
    if input.is_empty() {
        return false;
    }

    #[derive(serde::Deserialize, Default)]
    struct SourceInput {
        #[serde(default)]
        source: Option<String>,
        #[serde(default)]
        agent_type: Option<String>,
    }

    let parsed: SourceInput = match serde_json::from_str(input) {
        Ok(p) => p,
        Err(_) => return false,
    };

    // Skip agent-mode sessions (psyche wrapper, etc.)
    if parsed.agent_type.is_some() {
        return false;
    }
    // Skip team members
    if std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok() {
        return false;
    }

    // The source token is borrowed by the Phase 29 dispatch below; bind it
    // (statically "clear" or "compact" from the matched arm).
    let source_token: &str = match parsed.source.as_deref() {
        Some("clear") => "clear",
        Some("compact") => "compact",
        _ => return false,
    };

    // Resolve agent_id from OWL_SESSION_ID or parent_pid
    let owl_id = resolve_agent_id_for_reorientation();
    match owl_id {
        Some(id) => {
            // Phase 28 (SS-PSYDL-01): resolve is_live from info.json for the
            // resolved perch. Failure-mode chain collapses any read/parse
            // error to is_live=false — non-live perches still get the
            // existing <spacetime-reorientation> block (graceful degradation,
            // Pitfall 4).
            let is_live = std::fs::read_to_string(crate::common::owlery::info_file(&id))
                .ok()
                .and_then(|c| serde_json::from_str::<crate::common::types::InfoJson>(&c).ok())
                .map(|info| info.state == crate::common::types::PerchState::Live)
                .unwrap_or(false);
            super::resume::inject_reorientation(&id, is_live);

            // Phase 29 dispatch (D7): when is_live AND a prior session UUID
            // was snapshotted, deliver a FIRE_ECHO_COMMUNE_NOW control
            // message to `{id}-psyche`. Fire-and-return (D5): catch_unwind
            // absorbs TCP/spool panics; no synchronous block. UUID validation
            // happens inside dispatch_fire_echo_commune (T-29-05 mitigation).
            if is_live {
                if let Some(prior_uuid) = prior_session_id {
                    dispatch_fire_echo_commune(&id, prior_uuid, source_token);
                }

                // Phase 35 Plan 10 (SC1 gap-closure): clear/compact boundary
                // sync-prompt trigger. ACTIVE delivery of the
                // <spt-psyche-sync-prompt> AskUserQuestion to this live agent's
                // own perch. Gated on is_live (locked decision 1: live-agent
                // users only — non-live perches never receive the prompt). The
                // predicate inside deliver_sync_prompt_to_perch self-gates on
                // sync.state + gh presence, so no extra gating is needed here.
                let _ = crate::owl::sync_prompt::deliver_sync_prompt_to_perch(&id);
            }

            true
        }
        None => false, // No perch found -- skip reorientation
    }
}

/// Phase 29 Plan 02 Task 2: validate `prior_uuid` against the Claude UUID
/// shape (`8-4-4-4-12` lowercase/uppercase hex) and build the
/// `FIRE_ECHO_COMMUNE_NOW` control-message body. Returns `None` when the
/// UUID is malformed (T-29-05 mitigation: emit-site validation; Plan 03's
/// wrapper handler re-validates before subprocess spawn — defense in depth).
///
/// Wire format (consumed verbatim by `parse_fire_echo_commune_now` in
/// `src/live/wrapper/mod.rs`):
///
/// `FIRE_ECHO_COMMUNE_NOW prior_session={uuid} forward_to_self=true source={token}`
///
/// The literal prefix `FIRE_ECHO_COMMUNE_NOW prior_session=` lives ONLY in
/// `src/live/wrapper/mod.rs::FIRE_ECHO_COMMUNE_NOW_PREFIX` (Plan 01 Task 3).
/// This function imports the constant rather than inlining the literal so
/// emit-side and parse-side cannot drift.
fn build_fire_echo_commune_body(prior_uuid: &str, source_token: &str) -> Option<String> {
    if !is_claude_uuid_shape(prior_uuid) {
        return None;
    }
    Some(format!(
        "{}{} forward_to_self=true source={}",
        crate::live::wrapper::FIRE_ECHO_COMMUNE_NOW_PREFIX,
        prior_uuid,
        source_token
    ))
}

/// Hand-rolled Claude UUID shape check: `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-
/// [0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`. Avoids adding a `regex`
/// dependency for a single validation site (RESEARCH §"V5 Input Validation"
/// preferred this over a new crate).
fn is_claude_uuid_shape(s: &str) -> bool {
    // Required total length = 8+1+4+1+4+1+4+1+12 = 36.
    if s.len() != 36 {
        return false;
    }
    let bytes = s.as_bytes();
    // Hyphen positions (0-indexed): 8, 13, 18, 23.
    if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
        return false;
    }
    // Every other byte must be ASCII hex.
    for (i, &b) in bytes.iter().enumerate() {
        if i == 8 || i == 13 || i == 18 || i == 23 {
            continue;
        }
        if !b.is_ascii_hexdigit() {
            return false;
        }
    }
    true
}

/// Phase 29 Plan 02 Task 2: dispatch the FIRE_ECHO_COMMUNE_NOW control
/// message to `{self_id}-psyche` via `send::deliver_body`. UUID validation
/// happens inside `build_fire_echo_commune_body`; on validation failure
/// emits a single stderr log and skips delivery. Delivery is wrapped in
/// `std::panic::catch_unwind` so a TCP/spool panic cannot abort the
/// SessionStart hook (Pattern S7).
fn dispatch_fire_echo_commune(self_id: &str, prior_uuid: &str, source_token: &str) {
    let body = match build_fire_echo_commune_body(prior_uuid, source_token) {
        Some(b) => b,
        None => {
            eprintln!("[FIRE-EC] malformed prior_session, skipping");
            return;
        }
    };
    let psyche_target = format!("{}-psyche", self_id);
    let from = self_id.to_string();
    let _ = std::panic::catch_unwind(move || {
        crate::owl::send::deliver_body(&psyche_target, &from, &body);
    });
}

/// Resolve agent_id for re-orientation: try OWL_SESSION_ID env var, then parent_pid.
fn resolve_agent_id_for_reorientation() -> Option<String> {
    use crate::common::hook_output;

    if let Ok(sid) = std::env::var("OWL_SESSION_ID") {
        if !sid.is_empty() {
            if let Some(id) = hook_output::find_perch_by_session(&sid) {
                return Some(id);
            }
        }
    }
    hook_output::find_perch_by_parent_pid()
}

/// Extract session_id from hook stdin JSON and append OWL_SESSION_ID to CLAUDE_ENV_FILE.
fn write_session_id(input: &str) {
    if input.is_empty() {
        return;
    }

    // Parse session_id from the input
    #[derive(serde::Deserialize, Default)]
    struct SessionInput {
        #[serde(default)]
        session_id: Option<String>,
    }

    let parsed: SessionInput = match serde_json::from_str(input) {
        Ok(p) => p,
        Err(_) => return,
    };

    let session_id = match parsed.session_id {
        Some(sid) if !sid.is_empty() => sid,
        _ => return,
    };

    // Write to CLAUDE_ENV_FILE if available
    if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
        if !env_file.is_empty() {
            if let Ok(mut f) = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(&env_file)
            {
                let _ = writeln!(f, "export OWL_SESSION_ID=\"{}\"", session_id);
            }
        }
    }
}

/// Write OWL/LIVE env vars to CLAUDE_ENV_FILE. Returns true if successful.
/// In plugin mode, CLAUDE_ENV_FILE is guaranteed by the plugin system.
fn write_env_vars() -> bool {
    let bin_path = match std::env::current_exe() {
        Ok(p) => p.display().to_string().replace('\\', "/"),
        Err(_) => return false,
    };

    // Write to CLAUDE_ENV_FILE (provided by plugin system)
    if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
        if !env_file.is_empty() {
            if let Ok(mut f) = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(&env_file)
            {
                let _ = writeln!(f, "export OWL=\"{}\"", bin_path);
                let _ = writeln!(f, "export LIVE=\"{} live\"", bin_path);
                return true;
            }
        }
    }

    false
}

/// Extracts the value of the *last* `export OWL="..."` line from env-file contents.
/// Returns None if no such line is present.
fn parse_owl_from_env_file(contents: &str) -> Option<String> {
    let mut result: Option<String> = None;
    for line in contents.lines() {
        if let Some(rest) = line.strip_prefix("export OWL=\"") {
            if let Some(end) = rest.find('"') {
                result = Some(rest[..end].to_string());
            }
        }
    }
    result
}

/// Sync OWL/LIVE env vars from CLAUDE_ENV_FILE into ~/.claude/settings.json under the env key.
/// Only writes when drift is detected (different from post-trampoline paths).
/// Gracefully handles missing/malformed settings.json and missing home directory.
fn sync_settings_json() {
    // Step 1: Resolve OWL path from CLAUDE_ENV_FILE (authoritative post-trampoline)
    let env_file = match std::env::var("CLAUDE_ENV_FILE") {
        Ok(v) if !v.is_empty() => v,
        _ => return,
    };
    let contents = match std::fs::read_to_string(&env_file) {
        Ok(c) => c,
        Err(_) => return,
    };
    let owl_path = match parse_owl_from_env_file(&contents) {
        Some(p) => p,
        None => return,
    };
    let expected_owl = owl_path.clone();
    let expected_live = format!("{} live", owl_path);

    // Step 2: Resolve ~/.claude/settings.json
    let home = std::env::var("USERPROFILE")
        .or_else(|_| std::env::var("HOME"))
        .ok();
    let home = match home {
        Some(h) if !h.is_empty() => h,
        _ => {
            eprintln!("[owl] settings.json sync: no home dir");
            return;
        }
    };
    let settings_path = std::path::PathBuf::from(home)
        .join(".claude")
        .join("settings.json");

    // Step 3: Read + parse, bail gracefully on any issue
    let raw = match std::fs::read_to_string(&settings_path) {
        Ok(c) => c,
        Err(_) => {
            eprintln!("[owl] settings.json sync: file missing, skipping");
            return;
        }
    };
    let parsed: serde_json::Value = match serde_json::from_str(&raw) {
        Ok(v) => v,
        Err(_) => {
            eprintln!("[owl] settings.json sync: malformed JSON, skipping");
            return;
        }
    };
    if !parsed.is_object() {
        eprintln!("[owl] settings.json sync: malformed JSON, skipping");
        return;
    }

    // Step 4: Drift-check via pure helper
    let updated = match compute_settings_update(&parsed, &expected_owl, &expected_live) {
        Some(u) => u,
        None => return, // silent no-op
    };

    // Step 5: Write back (pretty-printed, direct write — atomic rename not required)
    let serialized = match serde_json::to_string_pretty(&updated) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[owl] settings.json sync: serialize failed: {}", e);
            return;
        }
    };
    if let Err(e) = std::fs::write(&settings_path, serialized) {
        eprintln!("[owl] settings.json sync: write failed: {}", e);
        return;
    }
    eprintln!(
        "[owl] synced settings.json env: OWL={} LIVE={}",
        expected_owl, expected_live
    );
}

/// Parse a version string like "1.2.3" into (major, minor, patch).
fn parse_version(s: &str) -> Option<(u32, u32, u32)> {
    let parts: Vec<&str> = s.split('.').collect();
    if parts.len() != 3 {
        return None;
    }
    let major = parts[0].parse::<u32>().ok()?;
    let minor = parts[1].parse::<u32>().ok()?;
    let patch = parts[2].parse::<u32>().ok()?;
    Some((major, minor, patch))
}

/// Pure drift-check function: returns Some(updated_value) when settings.json must be rewritten,
/// None when OWL/LIVE already match.
/// - If root is not an object -> None (caller must log and bail separately).
/// - If env key is missing or not an object -> creates it and returns Some(...).
/// - If env.OWL or env.LIVE differs from expected -> returns Some(...).
/// - If both match -> None.
fn compute_settings_update(
    current: &serde_json::Value,
    expected_owl: &str,
    expected_live: &str,
) -> Option<serde_json::Value> {
    let obj = current.as_object()?; // non-object root -> None
    let env_matches = obj
        .get("env")
        .and_then(|v| v.as_object())
        .map(|env| {
            env.get("OWL").and_then(|v| v.as_str()) == Some(expected_owl)
                && env.get("LIVE").and_then(|v| v.as_str()) == Some(expected_live)
        })
        .unwrap_or(false);
    if env_matches {
        return None;
    }
    // Clone the full object to preserve all sibling keys and their insertion order.
    let mut new_obj = obj.clone();
    // Rebuild env as a fresh object so non-object or missing env is handled uniformly.
    let mut env_obj = serde_json::Map::new();
    env_obj.insert("OWL".to_string(), serde_json::Value::String(expected_owl.to_string()));
    env_obj.insert("LIVE".to_string(), serde_json::Value::String(expected_live.to_string()));
    new_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
    Some(serde_json::Value::Object(new_obj))
}

/// Scan sibling version directories in the plugin cache for a newer owl.exe.
/// If found, rewrite CLAUDE_ENV_FILE to point to the newest version.
fn version_trampoline() {
    // Get current exe path, normalize to forward slashes
    let current_exe = match std::env::current_exe() {
        Ok(p) => p.display().to_string().replace('\\', "/"),
        Err(_) => return,
    };

    // Extract version from path: .../spt/<version>/owl.exe
    // We need at least 2 path components above the binary
    let parts: Vec<&str> = current_exe.rsplitn(3, '/').collect();
    if parts.len() < 3 {
        return; // Not enough path components
    }
    // parts[0] = "owl.exe", parts[1] = version dir, parts[2] = everything before
    let exe_name = parts[0];
    let current_version_str = parts[1];
    let spt_dir = parts[2]; // grandparent path (the spt/ directory)

    let current_version = match parse_version(current_version_str) {
        Some(v) => v,
        None => return, // Not running from a versioned plugin cache dir (e.g., cargo target/)
    };

    // Scan sibling version directories
    let spt_path = std::path::PathBuf::from(spt_dir);
    let entries = match std::fs::read_dir(&spt_path) {
        Ok(e) => e,
        Err(_) => return,
    };

    let mut best_version = current_version;
    let mut best_dir: Option<String> = None;

    for entry in entries.flatten() {
        let dir_name = entry.file_name().to_string_lossy().to_string();
        if let Some(ver) = parse_version(&dir_name) {
            let dir_path = spt_path.join(&dir_name);
            // Skip orphaned cache directories (Claude Code marks these for GC)
            if dir_path.join(".orphaned_at").exists() {
                continue;
            }
            // Check if owl.exe exists in this version dir
            let candidate = dir_path.join(exe_name);
            if ver > best_version && candidate.exists() {
                best_version = ver;
                best_dir = Some(dir_name);
            }
        }
    }

    // If a newer version was found, rewrite env vars to point to it
    if let Some(dir_name) = best_dir {
        let new_bin = format!("{}/{}/{}", spt_dir, dir_name, exe_name)
            .replace('\\', "/");

        // Update CLAUDE_ENV_FILE if available (per-session env vars)
        if let Ok(env_file) = std::env::var("CLAUDE_ENV_FILE") {
            if !env_file.is_empty() {
                if let Ok(content) = std::fs::read_to_string(&env_file) {
                    let mut new_lines: Vec<String> = Vec::new();
                    for line in content.lines() {
                        if line.starts_with("export OWL=") {
                            new_lines.push(format!("export OWL=\"{}\"", new_bin));
                        } else if line.starts_with("export LIVE=") {
                            new_lines.push(format!("export LIVE=\"{} live\"", new_bin));
                        } else {
                            new_lines.push(line.to_string());
                        }
                    }
                    let _ = std::fs::write(&env_file, new_lines.join("\n") + "\n");
                }
            }
        }

        eprintln!(
            "[owl] version trampoline: upgrading from {} to {}",
            current_version_str, dir_name
        );

        // Backfill: copy newer binary AND hooks.json into the current (old) version dir.
        // Claude Code may load hooks from the old version dir for this session.
        // Without binary backfill, hook commands added in newer versions fail with
        // "unrecognized subcommand". Without hooks.json backfill, new hook events
        // (e.g. Stop) are never registered and never fire.
        let new_dir = spt_path.join(&dir_name);
        let old_dir = spt_path.join(current_version_str);

        // Backfill binary
        let new_bin_path = new_dir.join(exe_name);
        if new_bin_path.exists() {
            if let Err(e) = std::fs::copy(&new_bin_path, old_dir.join(exe_name)) {
                eprintln!("[owl] version trampoline: backfill binary failed: {}", e);
            }
        }

        // Backfill hooks.json (new hook events won't fire without this)
        let new_hooks = new_dir.join("hooks").join("hooks.json");
        let old_hooks = old_dir.join("hooks").join("hooks.json");
        if new_hooks.exists() {
            if let Some(parent) = old_hooks.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            if let Err(e) = std::fs::copy(&new_hooks, &old_hooks) {
                eprintln!("[owl] version trampoline: backfill hooks.json failed: {}", e);
            }
        }
    }
}

/// Phase 18.8.1 REQ-REFRESH-01: rewrite `owlery/{owl_id}/info.json.session_id`
/// to the stdin-provided session_id on every SessionStart.
///
/// Closes `.planning/debug/doyle-listener-info-json-stale-session-id.md` —
/// previously info.json was written ONCE at listener startup and never
/// refreshed when Claude's foreground session rotated mid-run; echo-commune
/// then diffed the wrong jsonl UUID, producing empty [COMMUNE] blocks.
///
/// Perch identity resolution uses the existing `find_perch_by_session` →
/// `find_perch_by_parent_pid` fallback chain (same pattern as
/// `resolve_agent_id_for_reorientation`). Both helpers already filter out
/// psyche/spine/touch perches via `list_self_perches` (hook_output.rs) —
/// no risk of overwriting psyche/spine/touch session_id fields with
/// Claude-UUID-shaped values.
///
/// Race with listener startup (Pitfall 6): SessionStart fires BEFORE any
/// listener write on a fresh `/live start`, so ordering is
/// hook-then-listener in the rotation+restart case. If listener wins the
/// race, info.json is already current — acceptable. Best-effort, no locking.
///
/// Visibility: `pub` so tests/plugin_session_start_refresh.rs (a separate
/// integration-test crate) can drive the helper directly. Matches the
/// Phase 18.7.1 precedent of promoting `poll`/`new_alarm` from `pub(crate)`
/// to `pub` for integration-test access. REQ-REFRESH-01 Task 3 prerequisite.
pub fn refresh_info_json_session_id(input: &str) {
    use crate::common::hook_output;

    if input.is_empty() {
        return;
    }

    #[derive(serde::Deserialize, Default)]
    struct SessionInput {
        #[serde(default)]
        session_id: Option<String>,
    }
    let parsed: SessionInput = match serde_json::from_str(input) {
        Ok(p) => p,
        Err(_) => return,
    };
    let session_id = match parsed.session_id {
        Some(sid) if !sid.is_empty() => sid,
        _ => return,
    };

    // Resolve owning perch — session_id first (fast-path cache in
    // find_perch_by_session), parent_pid fallback for the
    // first-fire-after-rotation case where info.json.session_id is still
    // stale from the previous session.
    let owl_id = hook_output::find_perch_by_session(&session_id)
        .or_else(hook_output::find_perch_by_parent_pid);

    let Some(id) = owl_id else { return }; // No self-perch on this machine — nothing to refresh.

    // Patch info.json — promoted to pub(crate) in Plan 03 Task 1.
    hook_output::patch_session_id(&id, &session_id);
}

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

    #[test]
    fn match_returns_none() {
        let current = serde_json::json!({
            "env": { "OWL": "X", "LIVE": "X live" }
        });
        assert!(compute_settings_update(&current, "X", "X live").is_none());
    }

    #[test]
    fn drift_returns_updated_value_preserving_sibling_order() {
        let current = serde_json::json!({
            "permissions": { "allow": ["A", "B"] },
            "env": { "OWL": "OLD", "LIVE": "OLD live" }
        });
        let updated = compute_settings_update(&current, "NEW", "NEW live").unwrap();
        let obj = updated.as_object().unwrap();
        let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
        assert_eq!(keys, vec!["permissions", "env"]);
        assert_eq!(obj["env"]["OWL"], "NEW");
        assert_eq!(obj["env"]["LIVE"], "NEW live");
    }

    #[test]
    fn missing_env_object_is_created_preserving_siblings() {
        let current = serde_json::json!({
            "permissions": { "allow": ["A"] }
        });
        let updated = compute_settings_update(&current, "X", "X live").unwrap();
        let obj = updated.as_object().unwrap();
        let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
        assert_eq!(keys, vec!["permissions", "env"]);
        assert_eq!(obj["env"]["OWL"], "X");
        assert_eq!(obj["env"]["LIVE"], "X live");
    }

    #[test]
    fn non_object_env_is_replaced() {
        let current = serde_json::json!({
            "env": "oops"
        });
        let updated = compute_settings_update(&current, "X", "X live").unwrap();
        let env = updated.as_object().unwrap().get("env").unwrap();
        assert_eq!(env["OWL"], "X");
        assert_eq!(env["LIVE"], "X live");
        assert!(env.is_object());
    }

    #[test]
    fn non_object_root_returns_none() {
        let arr = serde_json::json!([1, 2, 3]);
        assert!(compute_settings_update(&arr, "X", "X live").is_none());
        let s = serde_json::json!("string");
        assert!(compute_settings_update(&s, "X", "X live").is_none());
    }

    #[test]
    fn partial_drift_returns_some() {
        // OWL matches, LIVE missing -> Some with both set
        let current = serde_json::json!({
            "env": { "OWL": "X" }
        });
        let updated = compute_settings_update(&current, "X", "X live").unwrap();
        let env = updated.as_object().unwrap().get("env").unwrap();
        assert_eq!(env["OWL"], "X");
        assert_eq!(env["LIVE"], "X live");
    }

    #[test]
    fn parse_owl_returns_last_export_line() {
        let input = "export OWL=\"/old/path/owl.exe\"\nexport LIVE=\"/old/path/owl.exe live\"\nexport OWL=\"/new/path/owl.exe\"\n";
        assert_eq!(parse_owl_from_env_file(input).as_deref(), Some("/new/path/owl.exe"));
    }

    #[test]
    fn parse_owl_returns_none_when_absent() {
        let input = "export OWL_SESSION_ID=\"abc\"\n";
        assert_eq!(parse_owl_from_env_file(input), None);
    }
}

#[cfg(test)]
mod snapshot_tests {
    //! Phase 29 Plan 02 Task 1: snapshot_prior_session_id helper tests.
    //!
    //! ENV_LOCK serializes SPT_HOME mutation across all tests in this module.
    //! Tests A-F exercise the helper directly; Test G is a static source-order
    //! assertion via include_str byte-index check.

    use super::*;
    use crate::common::types::{InfoJson, PerchState};
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Snapshot/restore SPT_HOME so tests do not leak into siblings.
    struct EnvSnapshot {
        spt_home: Option<String>,
    }
    impl EnvSnapshot {
        fn capture() -> Self {
            Self {
                spt_home: std::env::var("SPT_HOME").ok(),
            }
        }
    }
    impl Drop for EnvSnapshot {
        fn drop(&mut self) {
            match &self.spt_home {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    /// Build an info.json under SPT_HOME/owlery/<id>/ with the given
    /// `session_id` and `parent_pid` (so `find_perch_by_parent_pid` can match
    /// it) and create a ready file (so list_self_perches counts it online).
    fn write_test_perch(id: &str, session_id: &str, parent_pid: Option<u32>) {
        let perch = crate::common::owlery::perch_dir(id);
        std::fs::create_dir_all(&perch).unwrap();
        let info = InfoJson::new(
            id,
            "listen",
            std::process::id(),
            session_id,
            PerchState::Live,
            parent_pid,
            None,
        );
        std::fs::write(
            crate::common::owlery::info_file(id),
            serde_json::to_string(&info).unwrap(),
        )
        .unwrap();
        std::fs::write(crate::common::owlery::ready_file(id), "").unwrap();
    }

    #[test]
    fn snapshot_returns_some_when_info_json_has_session_id() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        crate::common::hook_output::clear_session_cache();

        // Production flow on `/clear`: info.json (written by listener startup
        // or a prior refresh) carries the PRIOR session UUID; stdin's session_id
        // is the new one Claude rotated to. Snapshot must return the prior.
        // Resolver uses `find_perch_by_parent_pid` (no patch side-effect),
        // so the test perch must carry the current process's parent_pid.
        let ppid = crate::common::types::get_parent_pid()
            .expect("test process should have a resolvable parent_pid");
        write_test_perch("alpha", "prior-uuid", Some(ppid));

        let input = r#"{"session_id":"new-uuid","source":"clear"}"#;

        let got = snapshot_prior_session_id(input);
        assert_eq!(got.as_deref(), Some("prior-uuid"));
    }

    #[test]
    fn snapshot_returns_none_when_info_json_session_id_empty() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        crate::common::hook_output::clear_session_cache();

        let ppid = crate::common::types::get_parent_pid()
            .expect("test process should have a resolvable parent_pid");
        write_test_perch("beta", "", Some(ppid));
        let input = r#"{"session_id":"new-uuid"}"#;

        let got = snapshot_prior_session_id(input);
        assert!(got.is_none(), "empty info.json.session_id must return None, got {:?}", got);
    }

    #[test]
    fn snapshot_returns_none_when_info_json_missing() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        crate::common::hook_output::clear_session_cache();

        // No perch on disk -> resolver returns None -> helper short-circuits.
        let input = r#"{"session_id":"new-uuid"}"#;
        let got = snapshot_prior_session_id(input);
        assert!(got.is_none());
    }

    #[test]
    fn snapshot_returns_none_when_input_empty() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        assert!(snapshot_prior_session_id("").is_none());
    }

    #[test]
    fn snapshot_returns_none_when_input_malformed_json() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        assert!(snapshot_prior_session_id("not json").is_none());
    }

    #[test]
    fn snapshot_returns_none_when_input_lacks_session_id() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _snap = EnvSnapshot::capture();
        // Valid JSON but no session_id key.
        assert!(snapshot_prior_session_id("{}").is_none());
        // Empty session_id is treated the same as missing.
        assert!(snapshot_prior_session_id(r#"{"session_id":""}"#).is_none());
    }

    /// Test G: source-order assertion via include_str byte-scan.
    /// Pins the non-negotiable ordering (Pitfall 1): snapshot must be called
    /// AFTER write_session_id and BEFORE refresh_info_json_session_id.
    #[test]
    fn snapshot_call_site_lives_between_write_and_refresh() {
        const SRC: &str = include_str!("plugin_session_start.rs");
        let write_idx = SRC
            .find("write_session_id(&input);")
            .expect("write_session_id call must exist in run()");
        let snapshot_idx = SRC
            .find("snapshot_prior_session_id(&input)")
            .expect("snapshot_prior_session_id call must exist in run()");
        let refresh_idx = SRC
            .find("refresh_info_json_session_id(&input);")
            .expect("refresh_info_json_session_id call must exist in run()");
        assert!(
            write_idx < snapshot_idx,
            "snapshot must be called AFTER write_session_id (write={} snapshot={})",
            write_idx,
            snapshot_idx
        );
        assert!(
            snapshot_idx < refresh_idx,
            "snapshot must be called BEFORE refresh_info_json_session_id (snapshot={} refresh={})",
            snapshot_idx,
            refresh_idx
        );
    }
}

#[cfg(test)]
mod dispatch_tests {
    //! Phase 29 Plan 02 Task 2: FIRE_ECHO_COMMUNE_NOW dispatch tests.
    //!
    //! Drives the helper layer (build_fire_echo_commune_body) rather than
    //! the full `inject_reorientation_if_needed` resolver chain — the seam
    //! returns `Option<String>` so panic-free, network-free unit tests can
    //! assert wire-format correctness, UUID validation, and source-order
    //! pinning (Test F).

    use super::*;

    const VALID_UUID: &str = "12345678-1234-1234-1234-123456789012";
    const VALID_UUID_UPPER: &str = "ABCDEF12-3456-7890-ABCD-EF0123456789";

    // ---- Test A: dispatch fires for source=clear ----
    #[test]
    fn build_body_test_a_source_clear_happy_path() {
        let body = build_fire_echo_commune_body(VALID_UUID, "clear")
            .expect("valid UUID + clear must produce a body");
        // Wire format = `FIRE_ECHO_COMMUNE_NOW prior_session={uuid} forward_to_self=true source=clear`
        // The literal prefix is sourced from FIRE_ECHO_COMMUNE_NOW_PREFIX (Plan 01 Task 3).
        let expected = format!(
            "FIRE_ECHO_COMMUNE_NOW prior_session={} forward_to_self=true source=clear",
            VALID_UUID
        );
        assert_eq!(body, expected);
        assert!(body.contains("FIRE_ECHO_COMMUNE_NOW"));
        assert!(body.contains("forward_to_self=true"));
        assert!(body.contains("source=clear"));
    }

    // ---- Test B: dispatch fires for source=compact ----
    #[test]
    fn build_body_test_b_source_compact_happy_path() {
        let body = build_fire_echo_commune_body(VALID_UUID, "compact")
            .expect("valid UUID + compact must produce a body");
        assert!(body.contains("source=compact"));
        assert!(body.contains(VALID_UUID));
        assert!(body.starts_with("FIRE_ECHO_COMMUNE_NOW prior_session="));
    }

    // ---- Test C: no dispatch when prior_uuid is None (caller-side gate) ----
    //
    // `dispatch_fire_echo_commune` is only invoked when prior_session_id is
    // Some(...). At the build-body layer, the matching invariant is "no UUID
    // ever produces an Option<String> at all" — verify by asserting that the
    // empty string fails validation (the closest helper-layer expression).
    #[test]
    fn build_body_test_c_empty_uuid_returns_none() {
        assert!(build_fire_echo_commune_body("", "clear").is_none());
    }

    // ---- Test D: no dispatch when is_live is false ----
    //
    // is_live is a caller-side gate inside `inject_reorientation_if_needed`.
    // Test the inverse contract: even when the UUID + source token are valid,
    // the caller must NOT invoke dispatch when is_live=false. The helper
    // itself cannot enforce that — but assert the body is well-formed so we
    // can prove the caller's gate is the only thing preventing dispatch.
    #[test]
    fn build_body_test_d_is_live_gate_lives_in_caller_not_helper() {
        // Helper happily produces a body — the is_live gate must be at the
        // call site. Verified by source-order test F + the caller invariant
        // that `dispatch_fire_echo_commune` is inside `if is_live { ... }`.
        let body = build_fire_echo_commune_body(VALID_UUID, "clear");
        assert!(body.is_some());
        // Source-order assertion: dispatch call site is inside an `if is_live`
        // gate followed by an inner `if let Some(prior_uuid) = prior_session_id`.
        const SRC: &str = include_str!("plugin_session_start.rs");
        let dispatch_call = SRC
            .find("dispatch_fire_echo_commune(&id, prior_uuid, source_token);")
            .expect("dispatch_fire_echo_commune call site must exist");
        // Scan backwards from dispatch_call for the nearest `if is_live {`
        // line — the gate must immediately precede the call site.
        let region_before = &SRC[..dispatch_call];
        let last_is_live = region_before
            .rfind("if is_live {")
            .expect("dispatch_fire_echo_commune must be inside an `if is_live` block");
        let last_some_prior = region_before
            .rfind("if let Some(prior_uuid) = prior_session_id")
            .expect("dispatch must be inside `if let Some(prior_uuid)` arm");
        assert!(last_is_live < last_some_prior);
        assert!(last_some_prior < dispatch_call);
    }

    // ---- Test E: source ∈ {clear, compact} — fresh path emits nothing ----
    //
    // Enforced by `inject_reorientation_if_needed`'s early-return on
    // non-clear/non-compact source tokens. Verify here that the match arm
    // in the source code only allows "clear" or "compact" through.
    #[test]
    fn build_body_test_e_source_match_arm_only_allows_clear_or_compact() {
        const SRC: &str = include_str!("plugin_session_start.rs");
        // The match arm in inject_reorientation_if_needed binds source_token
        // from `Some("clear") => "clear"` / `Some("compact") => "compact"`
        // and returns false on everything else.
        assert!(
            SRC.contains("Some(\"clear\") => \"clear\","),
            "must accept clear via match arm"
        );
        assert!(
            SRC.contains("Some(\"compact\") => \"compact\","),
            "must accept compact via match arm"
        );
        assert!(
            SRC.contains("_ => return false,"),
            "must early-return on other source tokens"
        );
    }

    // ---- Test F: Phase 28 emit happens BEFORE Phase 29 dispatch ----
    #[test]
    fn build_body_test_f_phase28_emit_before_phase29_dispatch() {
        const SRC: &str = include_str!("plugin_session_start.rs");
        let phase28 = SRC
            .find("super::resume::inject_reorientation(&id, is_live);")
            .expect("Phase 28 inject_reorientation call must exist");
        let phase29 = SRC
            .find("dispatch_fire_echo_commune(&id, prior_uuid, source_token);")
            .expect("Phase 29 dispatch call must exist");
        assert!(
            phase28 < phase29,
            "Phase 28 emit must precede Phase 29 dispatch (phase28={} phase29={})",
            phase28,
            phase29
        );
    }

    // ---- Test G: catch_unwind around delivery (panic-absorption) ----
    #[test]
    fn build_body_test_g_dispatch_wraps_deliver_in_catch_unwind() {
        const SRC: &str = include_str!("plugin_session_start.rs");
        // Confirm the dispatch site uses panic::catch_unwind so a deliver_body
        // panic (TCP/spool failure mode) cannot abort the SessionStart hook.
        let dispatch_fn = SRC
            .find("fn dispatch_fire_echo_commune(")
            .expect("dispatch_fire_echo_commune fn must exist");
        // Scan from the fn definition forward for catch_unwind.
        let region = &SRC[dispatch_fn..];
        assert!(
            region.contains("std::panic::catch_unwind"),
            "dispatch_fire_echo_commune must wrap deliver_body in catch_unwind (Pattern S7)"
        );
        assert!(
            region.contains("crate::owl::send::deliver_body"),
            "dispatch_fire_echo_commune must call send::deliver_body"
        );
    }

    // ---- UUID validation (T-29-05 mitigation) ----
    #[test]
    fn uuid_validation_accepts_lowercase_hex() {
        assert!(is_claude_uuid_shape(VALID_UUID));
    }

    #[test]
    fn uuid_validation_accepts_uppercase_hex() {
        assert!(is_claude_uuid_shape(VALID_UUID_UPPER));
    }

    #[test]
    fn uuid_validation_rejects_wrong_length() {
        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-12345678901")); // 35 chars
        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-1234567890123")); // 37 chars
        assert!(!is_claude_uuid_shape(""));
    }

    #[test]
    fn uuid_validation_rejects_missing_hyphens() {
        assert!(!is_claude_uuid_shape("123456781234-1234-1234-123456789012-")); // hyphen at wrong pos
        assert!(!is_claude_uuid_shape("12345678X1234-1234-1234-123456789012")); // wrong char at pos 8
    }

    #[test]
    fn uuid_validation_rejects_non_hex_chars() {
        assert!(!is_claude_uuid_shape("ZZZZZZZZ-1234-1234-1234-123456789012"));
        assert!(!is_claude_uuid_shape("path/trav-1234-1234-1234-123456789012"));
        assert!(!is_claude_uuid_shape("12345678-1234-1234-1234-12345678901G"));
    }

    #[test]
    fn build_body_rejects_malformed_uuid() {
        // T-29-05: emit-site validation. Malformed UUID returns None so
        // dispatch_fire_echo_commune can log + skip without sending.
        assert!(build_fire_echo_commune_body("not-a-uuid", "clear").is_none());
        assert!(build_fire_echo_commune_body("../etc/passwd", "clear").is_none());
        // Path-traversal-shaped 36-char strings still fail the hex check.
        assert!(build_fire_echo_commune_body("00000000-0000-0000-0000-000000000000", "clear").is_some()); // all-zero is still valid hex shape
    }
}

// Phase 35 Plan 10 (SC1 gap-closure): the `sync_prompt_tests` module that
// covered the old passive `should_emit_sync_prompt` + `build_sync_prompt_envelope`
// was REMOVED. The predicate moved to `crate::owl::sync_prompt`; its
// (expanded) truth-table corpus + the active-delivery block-shape tests now
// live there (and the render-path test lives in tests/sync_prompt_render.rs).

#[cfg(test)]
mod auto_pick_predicate_tests {
    //! Phase 33 Plan 03 AUTO-03/04: pure-predicate corpus for
    //! `should_emit_auto_pick`. Drives the helper layer rather than the
    //! `run()` wire so panic-free, network-free unit tests can assert
    //! allow-list gates table-driven (microseconds per case; no
    //! subprocess; no env mutation per Pattern S1).
    //!
    //! Corpus contract (RESEARCH §3c):
    //!   - >= 15 should-fire cases prefixed `fire_f{N}_`
    //!   - >= 17 should-NOT-fire cases prefixed `no_fire_n{N}_`
    //!   - source-order pin via include_str! byte-scan (Pattern S6)
    //!   - silent-rejection contract documenter test (pure-function
    //!     guarantee — no side effects on reject)
    //!
    //! Tests intentionally encode behavioral scenarios with distinct
    //! names even when the predicate-input tuple is identical — a
    //! future regression on a specific scenario surfaces with an
    //! actionable failure message rather than a single opaque failure.

    use super::*;

    fn snap_clean() -> AutoPickEnvSnapshot {
        AutoPickEnvSnapshot {
            handoff_child: false,
            trampoline_guard: false,
            echo_commune: false,
            skip_resume: false,
            team_name: false,
        }