# Phase 25: Perch Nesting + psyche-download Forked-Repo Layout — Pattern Map

**Mapped:** 2026-05-21
**Files analyzed:** 14 modified + 0 new files (no new modules — all extensions live in existing files)
**Analogs found:** 14 / 14 (all primitives have direct in-tree precedent)

Phase 25 is "wire-it-up, don't build-it." Every new identifier mirrors an
existing in-tree analog. The table below is the planner's authoritative
"copy from here" map.

## File Classification

| Target file | Surfaces added/changed | Role | Data flow | Closest analog | Match quality |
|-------------|------------------------|------|-----------|----------------|---------------|
| `src/common/owlery.rs` | `nested_perch_dir`, `is_worker_perch_path`, `is_psyche_perch_path`, `enumerate_perches` recursion + return-type bump, `sweep_own_orphaned_workers` | path resolver + predicate + enumerator + sweep | read-only path composition + fs lifecycle | self (`perch_dir`:153, `is_worker_perch`:351, `enumerate_perches`:370, `is_perch_online`:405) | exact |
| `src/common/tracked.rs` | `commit_project_payload` (new ~5-line wrapper) | tracked primitive composer | git commit | `commit_agent_payload`:1168 | exact |
| `src/live/context.rs` | `download_payload` reshape (project-section append) | payload composer | read-only composition | self `download_payload`:430 (memformat + live_context append blocks) | exact (extend in place) |
| `src/live/context.rs` | `run_save` / `run_amend_signoff` two-slice routing | CLI handler / write path | parser → composer → tracked write | self existing single-slice writers | role-match |
| `src/owl/echo_commune.rs` | D-12 inline `CURRENT_LIVE_CONTEXT` / `CURRENT_PROJECT_CONTEXT` injection in `run_echo_commune` prompt (~lines 360-376) | composer (haiku prompt) | read-only file read → format!() | self existing prompt format at line 361 | exact |
| `src/owl/echo_commune.rs` (or new sibling `parse_two_slice` helper) | `parse_two_slice(envelope) → TwoSlicePayload` | parser | parse XML-ish envelope | `parse_markers` at `src/live/wrapper/claude.rs:503` (bracket-marker form) | role-match (different delimiter) |
| `src/live/signoff.rs` | Envelope teaching of `compose_init_signoff_payload`; sweep trigger after `emit_signoff_trigger`; D-16 not here | composer + sweep call site | event-driven | self `compose_init_signoff_payload`:22 | exact |
| `src/live/start.rs` | D-16 psyche relocate hook (after collision check ~line 246, before `create_dir_all` ~line 322); D-20 sweep trigger after Self perch ready | fs lifecycle + sweep call site | fs rename + cascade | `src/common/tracked.rs:1578-1614` (rename + copy-fallback) | exact |
| `src/owl/resume.rs` | `inject_active_perch_context` enumeration recursion (~line 140); `is_worker_perch` call sites at 178, 305 swap to path form | enumerator | read-only walk | self `enumerate_perches` after Phase 25 reshape | exact |
| `src/owl/list.rs` + `src/common/list_filter.rs` | Recursion bump in `collect`:92, `list_result`:107; tree-shape rendering (D-18) | list renderer | read-only walk + format | self `list_filter::collect`:83 | exact |
| `src/owl/doctor.rs` | D-17 duplicate detection + D-21 orphan-worker count + D-18 nested visibility | doctor surface | read-only checks | self `check_stale_perches`:113 + `tracked::doctor_status_rows` pattern | role-match |
| `src/owl/cleanup.rs`, `src/owl/hook_subagent_stop.rs`, `src/owl/stop.rs`, `src/live/stop.rs`, `src/live/touch_loop.rs`, `src/owl/list_working.rs`, `src/live/list_psyches.rs`, `src/common/hook_output.rs` | Inline `read_dir(&owlery)` call sites migrated to `enumerate_perches` (Pitfall #1) | enumerator | read-only walk | self (each existing inline loop) | exact (mechanical migration) |
| `psyche.md` | D-11 envelope teaching: emit `<live-context>` + `<project-context>` slices around the existing payload body | embedded prompt | format contract | existing psyche.md envelope conventions + `compose_echo_commune_payload`:66 EVENT envelope | role-match |
| `tests/` (unit + integration) | New `#[test]` fns for each new helper; regenerated golden fixtures for tree render | test | n/a | `tests/handoff_integration.rs`, `tests/native_owl.rs`, `tests/golden_owl.rs` | exact |

---

## Pattern Assignments

### `src/common/owlery.rs` — `nested_perch_dir(parent, child_id)` (NEW path resolver)

**Analog:** `perch_dir(id)` at `src/common/owlery.rs:152-155`

**Imports pattern** — already in file (no new imports needed).

**Copy-from excerpt (analog, lines 152-155):**
```rust
/// Returns the perch directory for a given ID: owlery/<id>/
pub fn perch_dir(id: &str) -> PathBuf {
    owlery_dir().join(id)
}
```

**Target shape (Phase 25, place adjacent to `perch_dir`):**
```rust
/// Returns the nested-child perch directory: owlery/<parent>/nested/<child_id>/.
/// Used for Psyche and worker perches under their parent Self (D-01, D-04).
/// Pure path composition — does NOT create the directory.
pub fn nested_perch_dir(parent: &str, child_id: &str) -> PathBuf {
    owlery_dir().join(parent).join("nested").join(child_id)
}
```

**Rule:** No `fs::create_dir_all` here — caller owns lifecycle (matches the
posture documented at owlery.rs:85-89 for Phase 24 tracked helpers).

---

### `src/common/owlery.rs` — `is_worker_perch_path` + `is_psyche_perch_path` (NEW predicates)

**Analog:** `is_worker_perch(id: &str)` at `src/common/owlery.rs:351-361`

**Copy-from excerpt (analog, lines 345-361):**
```rust
/// Check if an ID is a worker/subagent perch (pattern: `{name}-w{digits}`).
/// Worker perches are ephemeral and should not trigger resume prompts or
/// surface in the Phase 26 picker scan.
pub(crate) fn is_worker_perch(id: &str) -> bool {
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}
```

**Target shape (Phase 25 — keep both forms during coexist per CONTEXT D-03 +
RESEARCH Pattern 2):**
```rust
/// D-03: path-aware worker classification. True iff `perch_path.parent.file_name`
/// == "nested" AND id matches `*-w{digits}`. The id-suffix retained as kind
/// discriminator within `nested/` (psyche siblings also live there).
pub(crate) fn is_worker_perch_path(perch_path: &Path) -> bool {
    if perch_path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())
        != Some("nested")
    {
        return false;
    }
    let id = match perch_path.file_name().and_then(|s| s.to_str()) {
        Some(s) => s,
        None => return false,
    };
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}

/// D-03: path-aware psyche classification. True iff `perch_path.parent.file_name`
/// == "nested" AND id ends with `-psyche`.
pub(crate) fn is_psyche_perch_path(perch_path: &Path) -> bool {
    if perch_path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())
        != Some("nested")
    {
        return false;
    }
    let id = match perch_path.file_name().and_then(|s| s.to_str()) {
        Some(s) => s,
        None => return false,
    };
    id.ends_with("-psyche")
}
```

**Rule:** Keep `is_worker_perch(&str)` form for legacy-flat coexist (D-16).
Doc-tag it as "legacy-flat only; prefer `_path` form" per RESEARCH Open Q1.

---

### `src/common/owlery.rs` — `enumerate_perches` recursion + return-type bump (D-04)

**Analog:** self at `src/common/owlery.rs:363-396`

**Copy-from excerpt (analog, lines 370-396):**
```rust
pub(crate) fn enumerate_perches() -> Vec<(String, crate::common::types::InfoJson)> {
    let dir = owlery_dir();
    let mut out = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(_) => return out,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        let info_path = path.join("info.json");
        let content = match std::fs::read_to_string(&info_path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let info: crate::common::types::InfoJson = match serde_json::from_str(&content) {
            Ok(i) => i,
            Err(_) => continue,
        };
        let id = match entry.file_name().to_str() {
            Some(s) => s.to_string(),
            None => continue,
        };
        out.push((id, info));
    }
    out
}
```

**Target shape (Phase 25 — RESEARCH Pattern 1):**
```rust
pub(crate) fn enumerate_perches() -> Vec<(String, crate::common::types::InfoJson, PathBuf)> {
    let mut out = Vec::new();
    let dir = owlery_dir();
    let Ok(entries) = std::fs::read_dir(&dir) else { return out; };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        // Layer 1: try Self perch at top level (also catches legacy flat).
        push_if_perch(&path, &mut out);
        // Layer 2 (D-04): owlery/<x>/nested/* — nested psyche + worker perches.
        let nested = path.join("nested");
        if let Ok(nested_entries) = std::fs::read_dir(&nested) {
            for child in nested_entries.flatten() {
                let cpath = child.path();
                if cpath.is_dir() { push_if_perch(&cpath, &mut out); }
            }
        }
    }
    // D-17 dedupe: nested wins when same id exists flat + nested.
    dedupe_nested_wins(out)
}

fn push_if_perch(path: &Path, out: &mut Vec<(String, InfoJson, PathBuf)>) {
    let info_path = path.join("info.json");
    let Ok(content) = std::fs::read_to_string(&info_path) else { return };
    let Ok(info) = serde_json::from_str::<InfoJson>(&content) else { return };
    let Some(id) = path.file_name().and_then(|s| s.to_str()) else { return };
    out.push((id.to_string(), info, path.to_path_buf()));
}
```

**Caller migration:** `src/live/pick_spec.rs:346` `for (id, info) in
owlery::enumerate_perches()` → `for (id, info, _path) in ...` (trivial).

---

### `src/common/owlery.rs` — `sweep_own_orphaned_workers` (NEW, D-20)

**Analog:** `is_perch_online`:405 + `enumerate_perches`:370 (composition of
existing predicates)

**Copy-from excerpt (`is_perch_online`, lines 405-421):**
```rust
pub(crate) fn is_perch_online(id: &str) -> bool {
    use crate::common::types::{self, PidValue};
    if !ready_file(id).exists() { return false; }
    let info_path = info_file(id);
    let content = match std::fs::read_to_string(&info_path) {
        Ok(c) => c, Err(_) => return false,
    };
    let info: types::InfoJson = match serde_json::from_str(&content) {
        Ok(i) => i, Err(_) => return false,
    };
    match info.pid {
        PidValue::Numeric(p) => types::is_process_alive(p),
        PidValue::Busy(_) => true,
    }
}
```

**Target shape (Phase 25 — RESEARCH Pattern 6, condensed):**
```rust
/// D-20: sweep own offline worker perches. Triggers: `$LIVE start` (after Self
/// ready, before first commune), signoff (after emit_signoff_trigger).
/// Predicate: is_worker_perch_path(path) AND !is_perch_online(id), with 30s
/// mtime grace for info.json-missing dirs (race with in-flight spawn).
/// Action: fs::remove_dir_all, best-effort silent-on-error. Returns count.
pub(crate) fn sweep_own_orphaned_workers(self_id: &str) -> usize {
    let mut cleaned = 0;
    // Nested workers: owlery/<self>/nested/<self>-w*/
    let nested = perch_dir(self_id).join("nested");
    if let Ok(entries) = std::fs::read_dir(&nested) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() { continue; }
            if !is_worker_perch_path(&path) { continue; }
            let Some(id) = path.file_name().and_then(|s| s.to_str()) else { continue };
            if !id.starts_with(&format!("{}-w", self_id)) { continue; }
            if is_perch_online(id) { continue; }
            if !safe_to_remove(&path) { continue; }
            if std::fs::remove_dir_all(&path).is_ok() { cleaned += 1; }
        }
    }
    // Legacy flat workers: owlery/<self>-w*/
    if let Ok(entries) = std::fs::read_dir(&owlery_dir()) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() { continue; }
            let Some(id) = path.file_name().and_then(|s| s.to_str()) else { continue };
            if !id.starts_with(&format!("{}-w", self_id)) { continue; }
            if !is_worker_perch(id) { continue; }
            if is_perch_online(id) { continue; }
            if !safe_to_remove(&path) { continue; }
            if std::fs::remove_dir_all(&path).is_ok() { cleaned += 1; }
        }
    }
    cleaned
}

fn safe_to_remove(path: &Path) -> bool {
    if !path.join("info.json").exists() {
        return path.metadata()
            .and_then(|m| m.modified())
            .map(|t| t.elapsed().map(|d| d.as_secs() > 30).unwrap_or(true))
            .unwrap_or(true);
    }
    true
}
```

---

### `src/common/tracked.rs` — `commit_project_payload` (NEW)

**Analog:** `commit_agent_payload` at `src/common/tracked.rs:1168-1179` +
underlying `commit_payload` at lines 1113-1158.

**Copy-from excerpt (analog, lines 1160-1179):**
```rust
/// Convenience wrapper for standard agent-scoped payload writers (commune,
/// signoff, echo, daemon-log, memformat). Materializes the agent worktree
/// (lazy per D-16), then commits via `commit_payload` with the standard
/// 500ms timeout and `TrailerScope::Agent`.
pub fn commit_agent_payload(
    agent_id: &str,
    files: &[&str],
    subject: &str,
) -> Result<(), TrackedError> {
    commit_agent_payload_with_timeout(
        agent_id,
        files,
        subject,
        Duration::from_millis(PAYLOAD_TIMEOUT_MS),
    )
}
```

**Target shape (Phase 25, place adjacent to `commit_agent_payload` ~line 1180):**
```rust
/// Phase 25 D-13: project-scoped payload commit. Materializes the project
/// worktree at `psyches/tracked/projects/{name}/` on first call (Phase 24
/// D-16 lazy creation via `ensure_project_worktree`), then commits via
/// `commit_payload` with `TrailerScope::Project` (D-08 trailer block omits
/// the redundant Project: trailer per Phase 24 D-08).
///
/// Soft-fail per D-02 — caller swallows `Err` with a single stderr warning.
pub fn commit_project_payload(
    project_name: &str,
    files: &[&str],
    subject: &str,
) -> Result<(), TrackedError> {
    let wt = ensure_project_worktree(project_name)?;
    commit_payload(
        &wt,
        files,
        subject,
        crate::common::git::TrailerScope::Project,
        Duration::from_millis(PAYLOAD_TIMEOUT_MS),
    )
}
```

**Note:** `ensure_project_worktree` ALREADY EXISTS at
`src/common/tracked.rs:458` (Phase 24). Don't duplicate.

---

### `src/live/context.rs::download_payload` — project section append (D-14/D-15)

**Analog:** self at `src/live/context.rs:430-535` — particularly the
front-matter-stripped live_context push at lines 502-512 and the
`if has_any` accumulator pattern.

**Copy-from excerpt (analog, lines 502-512):**
```rust
// Append context.md raw content if it exists. Pitfall 7: strip the
// file-head front-matter from the body push so the YAML fence does
// not surface twice in the output stream.
let file_path = ctx_path;
if file_path.exists() {
    if let Ok(content) = fs::read_to_string(&file_path) {
        let stripped = strip_file_head_frontmatter(&content);
        out.push_str(stripped);
        has_any = true;
    }
}
```

**Target shape (Phase 25 — insert at ~line 513, BEFORE the Pending Sections
append at line 527):**
```rust
// Phase 25 D-14: append project-scoped section when cwd resolves to a known
// project AND projects/<name>/<self_id>.md exists. Strict no-fallback per
// D-09 — folder-rename / cross-clone usage forfeits the project section
// until the next commune writes under the new name. Project file is written
// WITHOUT YAML front-matter (stamp stays on live_context.md only per D-14),
// so no strip_file_head_frontmatter call here.
if let Some(project_name) = owlery::derive_current_repo_names().first() {
    let proj_path = owlery::project_worktree_path(project_name)
        .join(format!("{}.md", self_id));
    if proj_path.exists() {
        if let Ok(content) = fs::read_to_string(&proj_path) {
            out.push_str(&content);
            has_any = true;
        }
    }
}
```

**Rule:** This is the SINGLE producer per D-15.
`download_payload_for_injection` (SessionStart) inherits the reshape
automatically.

---

### `src/owl/echo_commune.rs` — D-12 inline current-state injection

**Analog:** self at `src/owl/echo_commune.rs:360-376` (current prompt
construction).

**Copy-from excerpt (analog, lines 360-376):**
```rust
let prompt = format!(
    "You are the echo-commune summarizer for {self_id}.\n\n\
     Baseline: Read {self_md_abs}\n\
     This is the prior agent state snapshot.\n\n\
     Delta source: Read {excerpt_abs}\n\
     These are the user+assistant turns since the last commune. If the \
     excerpt looks incomplete or cut off, you may optionally Read {self_jsonl_abs}.\n\n\
     Task: Produce a delta summary — what is NEW in the excerpt relative to \
     the baseline. [...] \
     Output format:\n\
     [COMMUNE]\n\
     <your delta here>\n\
     [/COMMUNE]\n\n\
     [...]"
);
```

**Target shape (Phase 25 — RESEARCH Pattern 4; replace `Baseline: Read ...`
with inline body + add CURRENT_PROJECT_CONTEXT when present):**
```rust
let cwd_project = crate::common::owlery::derive_current_repo_names()
    .first()
    .cloned();
let live_path = crate::common::owlery::agent_worktree_path(self_id)
    .join("live_context.md");
let live_body = std::fs::read_to_string(&live_path)
    .unwrap_or_else(|_| "(none — first commune)".to_string());
let project_block = cwd_project
    .as_ref()
    .map(|name| crate::common::owlery::project_worktree_path(name)
        .join(format!("{}.md", self_id)))
    .and_then(|p| std::fs::read_to_string(&p).ok())
    .map(|body| format!("\n\nCURRENT_PROJECT_CONTEXT:\n{}", body))
    .unwrap_or_default();

let prompt = format!(
    "You are the echo-commune summarizer for {self_id}.\n\n\
     CURRENT_LIVE_CONTEXT:\n{live_body}\
     {project_block}\n\n\
     Delta source: Read {excerpt_abs}\n\
     [...]\n\
     Output format (D-11 two-slice envelope):\n\
     <live-context>\n\
     <agent slice — role / evolution / user + agent interactions>\n\
     </live-context>\n\
     <project-context>\n\
     <project slice — work done / current state / priorities / next task>\n\
     </project-context>\n\
     [...]"
);
```

**First-fire safety:** Pitfall 3 — `unwrap_or_else(|_| "(none — first
commune)")` matches the soft-fallback shape used by `download_payload`'s
existing `.ok()` chains.

---

### `parse_two_slice` parser (NEW, D-11)

**Analog:** `parse_markers` at `src/live/wrapper/claude.rs:503-523`
(bracket-marker form — closest in-tree precedent for delimited
substring extraction without an XML parser).

**Copy-from excerpt (analog, lines 503-523):**
```rust
pub fn parse_markers(response: &str) -> Vec<(String, String)> {
    let mut markers = Vec::new();
    for marker_type in &["REPLY", "NOTIFY", "COMMUNE"] {
        let open_tag = format!("[{}]", marker_type);
        let close_tag = format!("[/{}]", marker_type);
        let mut search_from = 0;
        while let Some(start) = response[search_from..].find(&open_tag) {
            let content_start = search_from + start + open_tag.len();
            if let Some(end) = response[content_start..].find(&close_tag) {
                let content = response[content_start..content_start + end].trim().to_string();
                if !content.is_empty() {
                    markers.push((marker_type.to_string(), content));
                }
                search_from = content_start + end + close_tag.len();
            } else {
                break;
            }
        }
    }
    markers
}
```

**Target shape (Phase 25 — new helper in `src/owl/echo_commune.rs` near top
of file, OR new `src/common/envelope.rs`; planner picks):**
```rust
/// D-11 two-slice envelope. Parses `<live-context>...</live-context>` and
/// `<project-context>...</project-context>` from haiku output. Fallback:
/// untagged whole-body output → all goes to live slot.
/// Robustness per RESEARCH Pattern 3:
///   - Neither tag → entire payload to live slot.
///   - Only one tag → other slot None (composer skips writing).
///   - Whitespace tolerant; tags case-sensitive lowercase.
pub(crate) struct TwoSlicePayload {
    pub live: Option<String>,
    pub project: Option<String>,
}

pub(crate) fn parse_two_slice(raw: &str) -> TwoSlicePayload {
    let live = extract_tag(raw, "live-context");
    let project = extract_tag(raw, "project-context");
    if live.is_none() && project.is_none() {
        return TwoSlicePayload {
            live: Some(raw.trim().to_string()),
            project: None,
        };
    }
    TwoSlicePayload { live, project }
}

fn extract_tag(haystack: &str, tag: &str) -> Option<String> {
    let open = format!("<{}>", tag);
    let close = format!("</{}>", tag);
    let start = haystack.find(&open)? + open.len();
    let end = haystack[start..].find(&close)?;
    Some(haystack[start..start + end].trim().to_string())
}
```

**Rule:** Do NOT escape body (D-11 free-form prose tolerated). Do NOT mirror
the `event_attr_escape` path — that's for the typed EVENT envelope, not the
two-slice payload tags.

---

### `src/live/start.rs` — D-16 psyche relocate (NEW hook)

**Analog:** `src/common/tracked.rs:1572-1614` — Phase 24 migration's
rename-with-copy-fallback for cross-volume moves.

**Copy-from excerpt (analog, lines 1576-1614):**
```rust
let mut moved: Vec<&str> = Vec::new();
for (legacy, new_name) in &movable {
    let dest = wt.join(new_name);
    let rename_result = std::fs::rename(legacy, &dest);
    match rename_result {
        Ok(()) => { moved.push(*new_name); }
        Err(_) => {
            // Cross-volume rename or other rename failure — fall back to
            // copy + remove.
            match std::fs::copy(legacy, &dest) {
                Ok(_) => match std::fs::remove_file(legacy) {
                    Ok(()) => moved.push(*new_name),
                    Err(e) => {
                        eprintln!("WARNING: copied {} to {} but could not remove legacy: {}",
                                  legacy.display(), dest.display(), e);
                        moved.push(*new_name);
                    }
                },
                Err(e) => {
                    eprintln!("WARNING: cannot migrate {}: rename failed and copy failed ({})",
                              legacy.display(), e);
                    continue;
                }
            }
        }
    }
}
```

**Target shape (Phase 25 — new helper called from `start.rs::run` after the
psyche collision check at lines 233-246 succeeds and BEFORE
`fs::create_dir_all(psyche_perch.join("inbox"))` at line 328):**
```rust
/// D-16: targeted psyche-only relocate on `$LIVE start` / `$LIVE revive`.
/// Moves legacy flat `owlery/<self>-psyche/` → `owlery/<self>/nested/<self>-psyche/`
/// exactly when legacy exists AND nested target absent AND the legacy psyche's
/// pid is dead (collision check at lines 233-246 already enforces this).
///
/// Best-effort per CONVENTIONS — failed move emits one stderr warning, boot
/// continues with perch at legacy location (D-04 enumeration still finds it).
fn relocate_legacy_psyche_perch(self_id: &str) {
    let psyche_id = format!("{}-psyche", self_id);
    let legacy_flat = owlery::owlery_dir().join(&psyche_id);
    let nested_target = owlery::nested_perch_dir(self_id, &psyche_id);

    if !legacy_flat.exists() || nested_target.exists() {
        return;  // Nothing to relocate, or already nested.
    }
    // Ensure parent owlery/<self>/nested/ exists before rename.
    if let Some(parent) = nested_target.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    match std::fs::rename(&legacy_flat, &nested_target) {
        Ok(()) => {}
        Err(rename_err) => {
            // Cross-volume (EXDEV) or locked-dir — fall back to recursive
            // copy + remove_dir_all. Mirror src/common/tracked.rs:1583
            // posture: emit one stderr line on hard failure, continue.
            match copy_dir_recursive(&legacy_flat, &nested_target) {
                Ok(()) => {
                    if let Err(e) = std::fs::remove_dir_all(&legacy_flat) {
                        eprintln!(
                            "WARNING: copied psyche perch {} -> {} but could not remove legacy: {}",
                            legacy_flat.display(), nested_target.display(), e
                        );
                    }
                }
                Err(copy_err) => {
                    eprintln!(
                        "WARNING: failed to relocate psyche perch {} -> {}: {} (and copy fallback failed: {}); continuing with legacy path",
                        legacy_flat.display(), nested_target.display(), rename_err, copy_err
                    );
                }
            }
        }
    }
}
```

**Note:** Need a small `copy_dir_recursive` helper (std `fs::copy` is
file-only). Planner can place it next to `relocate_legacy_psyche_perch` or
in `src/common/owlery.rs` as a generic util.

**Sweep wiring** — call `owlery::sweep_own_orphaned_workers(id)` after the
`fs::File::create(&self_ready)` at line 323 (fresh start) and the
corresponding reconnect ready-file write at line 302.

---

### `src/live/signoff.rs` — sweep wiring + envelope teaching

**Analog:** self `compose_init_signoff_payload`:22-40 (composer) +
`emit_signoff_trigger`:48-83 (post-write hook site).

**Copy-from excerpt for sweep wiring** (insert after `emit_signoff_trigger`
call at line 117):
```rust
// Phase 25 D-20: orphan-worker sweep after signoff event. Self-scoped —
// each agent cleans its own worker corpses. Best-effort; signoff payload
// delivery never blocked. Emit a single aggregated stderr line when N > 0.
let cleaned = crate::common::owlery::sweep_own_orphaned_workers(id);
if cleaned > 0 {
    eprintln!("cleaned {} orphaned worker perches", cleaned);
}
```

Apply same insertion after `emit_signoff_trigger(id, &psyche_id)` in
`signoff_result` at line 166.

**Envelope teaching:** `compose_init_signoff_payload` body (line 27-33)
currently produces a single prose block. Phase 25 wraps the `message`
content in `<live-context>...</live-context>` (and `<project-context>` when
the signoff caller has resolved a `cwd_project`) so the parse_two_slice
parser on the receiving Psyche side can route. Planner finalizes the exact
envelope shape inside the message — preserve the OUTER `<EVENT
type="init_signoff" ...>` wrapper (Phase 23 contract); the two-slice tags
live INSIDE the EVENT body.

---

### `src/owl/resume.rs` — enumeration migration + predicate swap

**Analog:** `inject_active_perch_context` at `src/owl/resume.rs:140-237` —
inline `fs::read_dir(&owlery)` walk; one of the 25 Pitfall #1 sites.

**Copy-from excerpt (analog, lines 140-181):**
```rust
pub(crate) fn inject_active_perch_context() {
    let owlery = owlery::owlery_dir();
    let my_ppid = types::get_parent_pid();
    let entries = match fs::read_dir(&owlery) {
        Ok(e) => e, Err(_) => return,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        let info_path = path.join("info.json");
        let content = match fs::read_to_string(&info_path) {
            Ok(c) => c, Err(_) => continue,
        };
        let info: types::InfoJson = match serde_json::from_str(&content) {
            Ok(i) => i, Err(_) => continue,
        };
        if info.state == types::PerchState::Psyche { continue; }
        if info.state == types::PerchState::Spine || info.state == types::PerchState::Touch { continue; }
        {
            let id = entry.file_name();
            let id = id.to_string_lossy();
            if owlery::is_worker_perch(&id) { continue; }
        }
        // ...
    }
}
```

**Target shape (Phase 25 — migrate to enumerate_perches; lines 178 + 305
predicates swap to path form):**
```rust
pub(crate) fn inject_active_perch_context() {
    let my_ppid = types::get_parent_pid();
    for (id, info, path) in owlery::enumerate_perches() {
        if info.state == types::PerchState::Psyche { continue; }
        if matches!(info.state, types::PerchState::Spine | types::PerchState::Touch) { continue; }
        // D-03 swap: path-aware worker detection.
        if owlery::is_worker_perch_path(&path) { continue; }
        // ... rest of body unchanged, but `id`/`info` now come from the tuple
    }
}
```

**Rule:** Same mechanical migration applies to all 25 Pitfall #1 sites
listed in RESEARCH. Each: replace `fs::read_dir(&owlery)` + manual parse
with `for (id, info, path) in enumerate_perches()`.

---

### `src/owl/list.rs` + `src/common/list_filter.rs` — recursion + D-18 tree render

**Analog:** `list_filter::collect` at `src/common/list_filter.rs:83-160`
(inline read_dir loop — the highest-ROI single-site Pitfall #1 fix per
RESEARCH).

**Copy-from excerpt (analog, lines 88-130):**
```rust
pub fn collect(
    passes_state: impl Fn(&PerchState) -> bool,
    mode: ListMode,
    here_names: Option<&[String]>,
    cleanup_orphans: bool,
) -> CollectedPerches {
    let owlery = owlery::owlery_dir();
    let entries = match fs::read_dir(&owlery) { Ok(e) => e, Err(_) => return ... };
    let mut dirs: Vec<_> = entries.filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir()).collect();
    dirs.sort_by_key(|d| d.file_name());
    let mut online: Vec<PerchEntry> = Vec::new();
    // ...
    for entry in &dirs {
        let path = entry.path();
        let id = entry.file_name().to_string_lossy().to_string();
        // ... compute is_online, info, etc.
    }
}
```

**Target shape (Phase 25):** Migrate to `enumerate_perches()` for the
collection step. D-18 tree render is a post-processing pass: after
collection produces `Vec<PerchEntry>`, group by parent
(`path.parent.parent.file_name` when `path.parent.file_name == "nested"`,
else top-level), then emit Self entries followed by their indented nested
children. Default-verbosity output stays terse per Phase 24.1; `--verbose`
includes path.

Pseudo-shape:
```rust
// After collection:
let (selves, nested): (Vec<_>, Vec<_>) = entries.into_iter()
    .partition(|e| e.path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str()) != Some("nested"));
for s in &selves {
    print_row(s, 0);
    for c in nested.iter().filter(|c| parent_of(c) == s.id) {
        print_row(c, 1);  // indented one level
    }
}
// Orphan nested (parent missing): emit at top level with a warning marker.
```

---

### `src/owl/doctor.rs` — D-17 dup + D-18 nested + D-21 orphan count

**Analog:** `check_stale_perches` at `src/owl/doctor.rs:113-180`. Similar
structure: walk owlery, classify, emit `DiagResult` rows.

**Copy-from excerpt (analog, lines 113-180):**
```rust
fn check_stale_perches(fix: bool) -> Vec<DiagResult> {
    let mut results = Vec::new();
    let owlery = owlery::owlery_dir();
    let entries = match fs::read_dir(&owlery) { Ok(e) => e, Err(_) => {...} };
    let mut stale_count = 0u32;
    let mut alive_count = 0u32;
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        let ready = path.join("ready");
        if !ready.exists() { continue; }
        let pid_alive = types::get_pid_from_info(&path)
            .map(|pid| types::is_process_alive(pid)).unwrap_or(false);
        let parent_alive = types::get_parent_pid_from_info(&path)
            .map(|ppid| types::is_process_alive(ppid)).unwrap_or(false);
        if pid_alive || parent_alive { alive_count += 1; }
        else {
            stale_count += 1;
            // ... emit DiagResult
        }
    }
    // ...
}
```

**Target shape (Phase 25):** Migrate to `enumerate_perches()`; add three
new checks emitted as `DiagResult` rows:
- **D-17 dup:** detect any id appearing in BOTH flat and nested layers.
  Emit `stale-leftover` row pointing at the flat sibling; auto-clean only
  if pid dead.
- **D-21 orphan-worker count:** call
  `sweep_own_orphaned_workers(...)`-style predicate WITHOUT removing —
  count `is_worker_perch_path(path) AND !is_perch_online(id)` matches.
  Emit a single-line count.
- **D-18 nested visibility:** every nested perch's parent dir
  (`path.parent.parent`) must contain `info.json`; orphan nested →
  WARN row.

---

### `psyche.md` — D-11 envelope teaching

**Analog:** existing psyche.md emits raw commune body; Phase 23 EVENT
envelope at `src/owl/echo_commune.rs:66-81` shapes the convention.

**Copy-from excerpt (`compose_echo_commune_payload`):**
```rust
format!(
    "<EVENT type=\"echo_commune\" from=\"{}\" timestamp=\"{}\"{} note=\"{}\">{}</EVENT>",
    ...
)
```

**Target shape for `psyche.md`:** Teach the haiku output format with the
two-slice envelope. Section content per CONTEXT D-10 taxonomy:

```
<live-context>
[Agent's role, how it has evolved, latest choices, user interactions,
agent-to-agent interactions]
</live-context>
<project-context>
[Work done, current state, outstanding priorities, next task, project-
bound items]
</project-context>
```

**Rebuild rule:** Per CLAUDE.md, `psyche.md` is `include_str!`-embedded.
Verify cargo dep-tracking re-includes on change (Pitfall #2, #7); if not,
add `cargo:rerun-if-changed=psyche.md` to `build.rs` (or wherever the
include lives).

---

### Tests

**Analogs by test type:**

| New test | Analog file | Analog pattern |
|----------|-------------|----------------|
| `owlery::enumerate_perches_recursive` (unit) | `tests/native_owl.rs` existing perch-walk tests | tempdir + create flat + nested + assert vec content |
| `owlery::is_worker_perch_path_*` (unit) | inline tests in `src/common/owlery.rs` | `#[cfg(test)] mod tests { ... }` |
| `owlery::sweep_own_orphaned_workers_*` (unit) | inline + tempdir | create offline worker, call sweep, assert removal |
| `tracked::commit_project_payload_*` (unit) | `src/common/tracked.rs` existing payload tests | seed bare repo, call, assert commit lands on `p-{name}` branch |
| `context::download_payload_appends_project_section` (unit) | adjacent tests in `src/live/context.rs` | tempdir + write live + project files + call + assert ordering |
| `echo_commune::parse_two_slice_*` (unit) | adjacent to `parse_markers` | feed strings, assert slot extraction |
| `echo_commune::prompt_contains_current_blocks` (unit) | `build_haiku_cmd` test seam at echo_commune.rs:382-384 | call prompt construction, grep for CURRENT_ blocks |
| `handoff_integration::psyche_relocate_on_start` (integration) | `tests/handoff_integration.rs` | full `$LIVE start` flow with legacy psyche pre-seeded |
| `native_owl::nested_worker_layout` (integration) | `tests/native_owl.rs` worker spawn tests | spawn worker, assert path = `owlery/<self>/nested/<self>-w*` |
| `golden_owl::list_tree_render` (golden) | `tests/golden_owl.rs` + `tests/golden/` | regenerate via `scripts/capture_golden.sh` on Unix |

---

## Shared Patterns

### Best-effort silent-on-error
**Source:** Phase 24 D-02 / Phase 32 D-05 / Phase 24.1 D-12.
**Apply to:** D-16 relocate, D-20 sweep, two-slice parser fallback,
commit_project_payload (caller swallows `Err`).

**Excerpt (`src/common/tracked.rs:1592-1598`):**
```rust
eprintln!(
    "WARNING: copied {} to {} but could not remove legacy: {}",
    legacy.display(), dest.display(), e
);
```

### Pure-path-composition rule for owlery.rs
**Source:** `src/common/owlery.rs:85-89` (header comment).
**Apply to:** `nested_perch_dir`, `is_worker_perch_path`, `is_psyche_perch_path`.

**Excerpt (header rule from line 85):**
```rust
// Pure path composition. None of these helpers create directories —
// directory + bare-repo lifecycle is owned by `crate::common::tracked` (see
// D-16: worktrees created on first relevant write, not eagerly).
```

### Coexist via fallback (legacy-flat + nested)
**Source:** Phase 25 D-16 + D-17.
**Apply to:** all enumeration sites (D-04 walks both layers), all predicates
(`is_worker_perch` id-only kept for legacy; `is_worker_perch_path` for
nested), all sweeps (gate on predicate, not on parent dir name).

### Front-matter on live_context only
**Source:** D-14 + `src/live/context.rs:502-512` `strip_file_head_frontmatter`.
**Apply to:** Project file body push in `download_payload` reshape (no
strip needed); D-12 inline read of project file (no strip needed); psyche
stamp stays anchored on live_context.md.

### `derive_current_repo_names().first()` for cwd_project
**Source:** Phase 24.1 D-09 + CONTEXT D-07/D-08.
**Apply to:** `download_payload` project lookup, `echo_commune` project file
inline read, commune/signoff project write path. SINGLE identity source.

**Excerpt (existing call site, `src/live/signoff.rs:68`):**
```rust
let names = crate::common::owlery::derive_current_repo_names();
```

---

## No Analog Found

No files in this phase fall into the "no analog" bucket. Every new identifier
has a direct in-tree precedent:

| New identifier | Why no fallback needed |
|----------------|------------------------|
| `nested_perch_dir` | Direct mirror of `perch_dir` |
| `is_worker_perch_path` | Direct mirror of `is_worker_perch` + parent-name check |
| `is_psyche_perch_path` | Same mirror pattern |
| `enumerate_perches` recursion | Same fn, one extra level + tuple bump |
| `sweep_own_orphaned_workers` | Composition of `is_perch_online` + `is_worker_perch_path` + `remove_dir_all` |
| `commit_project_payload` | Direct mirror of `commit_agent_payload` |
| `parse_two_slice` | Lighter twin of `parse_markers` |
| D-16 relocate hook | Direct mirror of `tracked.rs:1572-1614` rename+fallback |
| D-20 sweep wiring | Standard side-effect call after existing trigger points |
| D-12 inline injection | Edit-in-place in existing prompt format!() |
| D-14 project append | Edit-in-place in existing `download_payload` accumulator |
| D-18 tree render | Standard partition + indented print on existing collected vec |
| D-17 dup detection | Standard hashmap dedupe on enumerate output |
| D-21 orphan count | Sweep predicate without action |

---

## Metadata

**Analog search scope:** `src/common/`, `src/live/`, `src/owl/`, `tests/`,
`psyche.md`, `.planning/phases/23/`, `.planning/phases/24/`,
`.planning/phases/24.1/`, `.planning/phases/30/`.

**Files scanned:** ~25 (every file:line cited in CONTEXT canonical_refs +
RESEARCH Standard Stack table).

**Pattern extraction date:** 2026-05-21

**Confidence:** HIGH — every analog read directly; every excerpt is a
verbatim quote from the cited file:line with line numbers preserved.
