# Phase 25: Perch Nesting (Psyche + Workers) + Wire psyche-download to Forked-Repo Layout - Research

**Researched:** 2026-05-21
**Domain:** Rust binary refactor — owlery path layout, psyche-download payload reshape, envelope parsing, lifecycle sweeps
**Confidence:** HIGH on touchpoints + Rust mechanics (codebase inspection); MEDIUM on Windows `fs::rename` corner cases (existing precedent but not exhaustively tested for locked-dir cases)

## Summary

Phase 25 is a pure-restructure-plus-wire-up phase. The 21 decisions in CONTEXT.md fully constrain design space; research's job is to surface implementation specifics — concrete file:line touchpoints, existing analog patterns to mirror, and the dependency graph between slices. No new commands, no new info.json schema, no new external deps.

The work decomposes into seven independent-or-near-independent slices: (1) enumeration / predicate plumbing (`owlery.rs` + 25 read-dir call sites), (2) D-16 psyche relocate hook in start/revive, (3) D-20 orphan-worker sweep at two trigger points, (4) `download_payload` reshape with optional project section, (5) project worktree write primitives (mostly already exist), (6) commune/signoff two-slice envelope (psyche.md prompt + parser + composer), (7) doctor + list tree rendering. The graph: (1) and (5) gate everything; (2)/(3) chain off (1); (4) chains off (5); (6) is independent (psyche.md is the input contract); (7) is leaf.

**Primary recommendation:** Plan as 5 waves: Wave 1 = enumeration/predicate plumbing + project commit primitive. Wave 2 = D-16 relocate + D-20 sweep. Wave 3 = `download_payload` reshape + envelope parser + composer (commune + signoff). Wave 4 = psyche.md prompt rewrite + D-12 inline injection. Wave 5 = doctor/list tree rendering + tests. Worker-id naming stays `{parent}-w{N}`. `ensure_project_worktree` already exists at `src/common/tracked.rs:458` — no new function needed; only `commit_project_payload` is missing and is a 5-line wrapper around the existing `commit_payload` funnel.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Path composition (nested_perch_dir) | `src/common/owlery.rs` | — | Established pattern: pure path helpers, no FS side effects (lines 153-177). |
| Enumeration (recursion bump) | `src/common/owlery.rs::enumerate_perches` | All 25 inline `read_dir(&owlery)` call sites | Shared helper is single-source; inline sites need recursion bump too because they bypass enumerate_perches. |
| Path-aware predicates (is_worker/is_psyche_perch) | `src/common/owlery.rs` | All 3 existing `is_worker_perch` call sites | Predicate logic centralized; callers route through it. |
| Lifecycle (rename, mkdir) | `src/common/tracked.rs` + call sites in `src/live/start.rs` | — | Established posture: owlery.rs path-pure, lifecycle in tracked + call sites. |
| D-20 orphan-worker sweep | `src/live/start.rs` + `src/live/signoff.rs` | `src/common/owlery.rs` (new sweep helper) | Sweep is policy + scoped action — belongs in `live/` with helper in `common/`. |
| Two-slice envelope parsing | `src/owl/echo_commune.rs` (new helper) | `src/live/signoff.rs`, `src/live/context.rs` | Composer module owns parse mirror to compose; called by both commune dispatch and signoff write paths. |
| Project worktree write | `src/common/tracked.rs::commit_project_payload` (new thin wrapper) | `src/live/context.rs` writers | New 5-line wrapper around `commit_payload` with `TrailerScope::Project`. |
| download_payload reshape | `src/live/context.rs::download_payload` | `src/owl/resume.rs` SessionStart hook | Single producer (D-15); SessionStart inherits via `download_payload_for_injection`. |
| Tree render | `src/owl/list.rs` + `src/common/list_filter.rs` | `src/owl/doctor.rs` | Existing list flow needs hierarchical post-processing on the flat `Vec<PerchEntry>`. |

## Standard Stack

No new external dependencies. All work uses crates already in Cargo.toml: `serde`/`serde_json`, `std::fs`, `std::path`, `chrono`, `rusqlite`.

### Existing Infrastructure (verified)

| Asset | Location | Phase 25 Use |
|-------|----------|--------------|
| `perch_dir(id)` | `src/common/owlery.rs:153` | Self perch resolver — unchanged. |
| `is_worker_perch(id: &str)` | `src/common/owlery.rs:351` | Swap to path-aware form per D-03. |
| `enumerate_perches` | `src/common/owlery.rs:370` | Recursion bump per D-04 + return path. |
| `is_perch_online(id: &str)` | `src/common/owlery.rs:405` | Reuse for D-20 sweep predicate. |
| `derive_current_repo_names()` | `src/common/owlery.rs:447` | Direct reuse for cwd_project (D-07). |
| `agent_worktree_path` | `src/common/owlery.rs:105` | Reuse unchanged. |
| `project_worktree_path` | `src/common/owlery.rs:111` | Reuse unchanged. |
| `project_branch` | `src/common/owlery.rs:121` | `p-<name>` prefix — confirmed. |
| `ensure_agent_worktree` | `src/common/tracked.rs:450` | Pattern to mirror. |
| `ensure_project_worktree` | `src/common/tracked.rs:458` | **ALREADY EXISTS** — no new fn needed. |
| `commit_agent_payload` | `src/common/tracked.rs:1168` | Pattern to mirror for `commit_project_payload`. |
| `commit_payload` | `src/common/tracked.rs:1113` | Underlying funnel; accepts `TrailerScope::Project` already. |
| `download_payload` | `src/live/context.rs:430` | Reshape per D-14/D-15. |
| `compose_init_signoff_payload` | `src/live/signoff.rs:22` | Pattern for envelope composer mirror. |
| `compose_echo_commune_payload` | `src/owl/echo_commune.rs:66` | EVENT envelope structural twin for D-11 parser pattern. |
| `parse_markers` | `src/live/wrapper/claude.rs:503` | Existing `[COMMUNE]`/`[/COMMUNE]` parser — adjacent pattern for D-11 envelope extraction. |

[VERIFIED: codebase Read at sites listed]

## Package Legitimacy Audit

Not applicable — no new external packages. All implementation uses existing `Cargo.toml` deps (`serde`, `chrono`, `rusqlite`, `clap`, `ctrlc`, `libc`, `windows-sys`). No `cargo add` step in any plan.

## Architecture Patterns

### System Architecture (Phase 25 data flow)

```
                                 enumerate_perches()
                                       |
                  ┌────────────────────┼────────────────────┐
                  v                    v                    v
            Layer 1: owlery/*/    Layer 2: owlery/*/nested/*/   (new — D-04)
              Self perches       Psyche + worker perches
                  |                    |
                  v                    v
            (id, info, path)      (id, info, path)  ← tuple shape change
                  |                    |
                  └──────────┬─────────┘
                             v
            consumer call sites (25 of them, see Pitfall #1)


   live start                                   compose haiku prompt
     |                                                  |
     v                                                  v
   D-16 psyche relocate?  ──► fs::rename                D-12 read CURRENT_*
     |                          (cross-volume fall-     contents inline
     v                           back: copy + remove)         |
   D-20 worker sweep  ─────► remove_dir_all                   v
     |                       (is_worker + !online)      psyche.md prompt
     v                                                  + 2-slice envelope
   poll loop                                                  |
                                                              v
                                                       <live-context>
                                                       <project-context>
                                                              |
                                                       parse_two_slice()
                                                              |
                              ┌───────────────────────────────┤
                              v                               v
                  agents/<id>/live_context.md       projects/<cwd_project>/<id>.md
                  (always)                          (when cwd resolves)
                              |                               |
                              v                               v
                  commit_agent_payload              commit_project_payload (NEW)


   $LIVE psyche-download (CLI) ─┐
                                ├──► download_payload(self_id)
   SessionStart injection ──────┘    │
   (download_payload_for_injection)  │
                                     v
                  <memformat> + <psyche-stamp/> + <current/> + drift directive
                          + live_context.md body (front-matter stripped)
                          + projects/<cwd>/<id>.md body  (NEW, conditional on cwd)
                          + Pending Commune/Signoff (Phase 30 D11, unchanged)
```

### Pattern 1: Recursion bump on enumerate_perches (D-04)

**What:** Walk one extra level beneath each top-level `owlery/<id>/` dir when that dir contains a `nested/` subfolder.

**When to use:** Single source — `enumerate_perches` plus the ~25 inline `fs::read_dir(&owlery)` call sites listed in Pitfall #1.

**Example (target shape):**
```rust
// Source: src/common/owlery.rs (Phase 25 reshape of line 370)
pub(crate) fn enumerate_perches() -> Vec<(String, InfoJson, PathBuf)> {
    let mut out = Vec::new();
    let dir = owlery_dir();
    let Ok(entries) = 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 this level.
        push_if_perch(&path, &mut out);
        // Layer 2: walk owlery/<x>/nested/* (D-04).
        let nested = path.join("nested");
        if let Ok(nested_entries) = fs::read_dir(&nested) {
            for child in nested_entries.flatten() {
                let cpath = child.path();
                if cpath.is_dir() { push_if_perch(&cpath, &mut out); }
            }
        }
    }
    out
}
```

The return type changes from `Vec<(String, InfoJson)>` to `Vec<(String, InfoJson, PathBuf)>` (planner choice per D-04 LOCKED). The single existing caller (`src/live/pick_spec.rs:346`) needs a `.0`/`.1` tweak — trivial.

### Pattern 2: Path-aware predicate (D-03)

**What:** Predicate accepts `&Path` and combines path-parent check with id-suffix check.

**Example:**
```rust
// Source: src/common/owlery.rs (Phase 25 reshape of line 351)
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,
    };
    // Existing id-regex form retained as kind discriminator within nested/.
    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 }
}

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")
}
```

**Keep the existing `is_worker_perch(id: &str)`** as a deprecated id-only form for legacy flat workers (coexist per D-16). Mark with `#[deprecated]` or just keep both for the duration of coexist. CONTEXT D-03 says "drop the standalone id-regex-only worker detection ... once all callers route through the path form" — read as eventual, not blocking Phase 25.

### Pattern 3: Two-slice envelope parser (D-11)

**What:** Parse `<live-context>...</live-context>` and `<project-context>...</project-context>` slices from haiku output. Mirror the structural shape of the existing `<EVENT type=...>` envelope at `src/owl/echo_commune.rs:66`, but lightweight (no attribute escape).

**Robustness rules (planner-locked from research):**

- If neither tag found → entire payload goes to `live_context.md` slot, `project_context` is `None`. (Back-compat: lets first-fire output from an updated haiku that ignores the new tags still land somewhere.)
- If only `<live-context>` found → project slot is `None`.
- If only `<project-context>` found → unusual; still extract project; live slot is empty `None`. (Composer should NOT zero out an existing live_context.md; only write the slot that was provided.)
- Whitespace between tags ignored; nested unknown tags inside each slice preserved verbatim (treated as opaque body).
- Tags are case-sensitive lowercase to match psyche.md prompt convention.
- Simple regex-or-find-based parser, NOT a full XML parser. Existing `compose_echo_commune_payload` uses raw `format!` and `compose_init_signoff_payload` likewise — no XML library on the wire.

**Example (target shape):**
```rust
// Source: new helper in src/owl/echo_commune.rs or new src/common/envelope.rs
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");
    // Fallback: nothing tagged → whole payload to live slot.
    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())
}
```

### Pattern 4: D-12 inline current-state injection

**What:** Read both `agents/<id>/live_context.md` and `projects/<cwd_project>/<id>.md` at fire time and inline them as labeled blocks in the haiku prompt.

**Site:** `src/owl/echo_commune.rs::run_echo_commune` lines 360-376 (the current prompt build site). Today's prompt already uses `self_md_abs` via "Baseline: Read {self_md_abs}" — Phase 25 replaces this with inline content.

**Behavior on missing files:**
- `agents/<id>/live_context.md` missing → emit `CURRENT_LIVE_CONTEXT:\n(none — first commune)\n`
- `projects/<cwd>/<id>.md` missing or no cwd_project → omit the `CURRENT_PROJECT_CONTEXT:` block entirely

**Example (target shape):**
```rust
// Source: src/owl/echo_commune.rs (Phase 25 reshape of lines 360-376)
let cwd_project = derive_current_repo_names().first().cloned();
let live_path = agent_worktree_path(self_id).join("live_context.md");
let live_body = fs::read_to_string(&live_path).unwrap_or_else(|_| "(none — first commune)".to_string());
let project_block = cwd_project
    .as_ref()
    .map(|name| project_worktree_path(name).join(format!("{}.md", self_id)))
    .and_then(|p| 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\
     [...]"
);
```

[VERIFIED: codebase Read of src/owl/echo_commune.rs:360-376]

### Pattern 5: D-16 psyche relocate via fs::rename + copy-fallback

**What:** Move legacy flat `owlery/<self>-psyche/` to `owlery/<self>/nested/<self>-psyche/` exactly when both (a) legacy flat exists AND (b) nested target absent AND (c) `$LIVE start` or `$LIVE revive` is firing for `<self>`.

**Mechanics:** Mirror `src/common/tracked.rs:1578`'s precedent — try `fs::rename` first, fall back to `fs::copy` + `fs::remove_file`/`remove_dir_all` on `EXDEV` cross-volume error or any other rename failure. `fs::rename` is atomic on the same volume on both Windows + Unix; cross-volume falls through to copy+remove.

**Locked-dir concern (Windows-specific):**
Phase 25 fires the relocate while the OLD psyche's wrapper process may still hold the perch dir's inbox open. Mitigation: D-16 fires on `$LIVE start`/`revive` ONLY when the legacy psyche's pid is dead (otherwise it's a normal collision, not a stale relocate). The collision check at `src/live/start.rs:233-246` already enforces this — psyche perch with live pid → COLLISION exit. Phase 25's relocate hook should run AFTER the existing collision check but BEFORE the new `fs::create_dir_all` of the nested perch dir. So `fs::rename` is operating on a dir whose owning process is dead — Windows file handles released, rename succeeds.

**State-bearing paths inside the moved dir:** `info.json` (pid, parent_pid, session_id), `ready` file, `inbox/` directory contents, possibly `.last-commune-epoch`, possibly a SQLite spool (`.spool.db`). None of these embed the perch dir's absolute path. Confirmed by inspecting `InfoJson` schema (no path field) and the spool path resolution (always derived from `inbox_dir(id)` at read time, never stored). **No path rewrites needed after rename.**

**Best-effort posture per CONVENTIONS Phase 24 D-02 + 32 D-05: failed move emits one stderr warning and boot continues with perch at its current (legacy flat) location.**

[VERIFIED: codebase Read of src/common/tracked.rs:1578 + src/live/start.rs:233-246; VERIFIED: src/common/types::InfoJson has no path field]

### Pattern 6: D-20 orphan-worker sweep

**What:** Walk own-perch nested workers + legacy flat workers, remove those that are not online.

**Sites:**
- `src/live/start.rs::run` after Self perch `ready` file created (line 323-324 in the fresh-start branch and around 302 in the reconnect branch), BEFORE wrapper spawn at line 350+.
- `src/live/start.rs::live_start_result` mirror site after line 611.
- `src/live/signoff.rs::run` after `emit_signoff_trigger(id, &psyche_id)` at line 117 succeeds.
- `src/live/signoff.rs::signoff_result` mirror at line 166.

**Predicate (CONTEXT D-20 LOCKED):**
```
is_worker_perch_path(perch) AND !is_perch_online(perch_id)
  AND info.json exists  AND  (pid dead  OR  (info.json missing AND mtime > 30s))
```

The "info.json + 30s grace" mitigates the just-spawned-but-not-yet-info-written race. `mtime > 30s` means dir mtime in the past more than 30s ago (i.e. the dir hasn't been touched in the grace window).

**Scope:** ONLY this Self's workers — nested under `owlery/<self>/nested/<self>-w*` AND legacy flat siblings `owlery/<self>-w*`. NOT other agents' workers.

**Action:** `fs::remove_dir_all`, ignore errors. After loop, aggregated stderr `cleaned N orphaned worker perches` (only when N > 0).

**Example helper site:**
```rust
// Source: new helper in src/common/owlery.rs
pub(crate) fn sweep_own_orphaned_workers(self_id: &str) -> usize {
    let mut cleaned = 0;
    let self_perch = perch_dir(self_id);
    let nested = self_perch.join("nested");
    // Nested workers: owlery/<self>/nested/<self>-w*/
    if let Ok(entries) = 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 id = match path.file_name().and_then(|s| s.to_str()) {
                Some(s) if s.starts_with(&format!("{}-w", self_id)) => s.to_string(),
                _ => continue,
            };
            if is_perch_online(&id) { continue; }
            if !safe_to_remove(&path) { continue; }
            if fs::remove_dir_all(&path).is_ok() { cleaned += 1; }
        }
    }
    // Legacy flat workers: owlery/<self>-w*/
    if let Ok(entries) = fs::read_dir(&owlery_dir()) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() { continue; }
            let id = match path.file_name().and_then(|s| s.to_str()) {
                Some(s) if s.starts_with(&format!("{}-w", self_id))
                    && is_worker_perch(s) => s.to_string(),
                _ => continue,
            };
            if is_perch_online(&id) { continue; }
            if !safe_to_remove(&path) { continue; }
            if fs::remove_dir_all(&path).is_ok() { cleaned += 1; }
        }
    }
    cleaned
}

fn safe_to_remove(path: &Path) -> bool {
    // info.json absent? require dir mtime > 30s old (grace for in-flight spawn).
    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
}
```

### Pattern 7: Project worktree commit primitive (D-13)

**What:** Thin parallel of `commit_agent_payload` that calls `ensure_project_worktree` then `commit_payload` with `TrailerScope::Project`.

**Existing infrastructure:** `ensure_project_worktree(project_name)` already lives at `src/common/tracked.rs:458`. `commit_payload` already accepts `TrailerScope::Project` at line 1117. Only the convenience wrapper is missing.

**Example:**
```rust
// Source: new in src/common/tracked.rs near commit_agent_payload (line 1168)
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),
    )
}
```

### Pattern 8: download_payload reshape (D-14/D-15)

**What:** Append project-section body after live_context.md body, when cwd resolves to a known project AND the project file exists.

**Site:** `src/live/context.rs:430` (`download_payload`). Insertion BETWEEN the existing front-matter-stripped live_context.md push (line 506-512) and the Pending sections append (line 527-532).

**Strict no-fallback (D-09):** Use `derive_current_repo_names().first()` as the lookup key; if `projects/<name>/<self_id>.md` doesn't exist on disk, skip silently. No tiered lookup, no second-name try.

**Project file front-matter handling:** Project files are written WITHOUT YAML front-matter (per D-14: stamp stays on live_context.md only). So `strip_file_head_frontmatter` is not needed for the project body. Append raw.

**Example:**
```rust
// Source: src/live/context.rs (Phase 25 insert at ~line 513 in download_payload)
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;  // already true if live_context existed, but defensive
        }
    }
}
```

### Anti-Patterns to Avoid

- **Don't reach into other agents' nested dirs.** Each agent sweeps its own workers (D-20). Cross-agent sweep is out of scope.
- **Don't drop `is_worker_perch(&str)` id-only form until coexist is proven.** Legacy flat perch ids still need string-only classification.
- **Don't add `parent_id` to info.json.** D-02 LOCKED — path is the only source of truth.
- **Don't migrate workers.** D-16 LOCKED — psyche relocate ONLY. Workers die naturally.
- **Don't bake `owlery/<id>/` flat assumption in path string concat.** Use `perch_dir(id)` for Self, `nested_perch_dir(parent, child)` for psyche/workers (new helper). Greppable bake-in audit in Pitfall #1.
- **Don't gate worker cleanup on "child of nested/".** D-05 LOCKED: gate on `is_worker_perch`, not path parent. Legacy flat workers also get swept.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Project worktree directory + branch | New `ensure_project_worktree` impl | `tracked::ensure_project_worktree(name)` at line 458 | Already exists; Phase 24 D-16. |
| Project worktree commit | New `commit_project_payload` from scratch | Thin wrapper around `tracked::commit_payload(.., TrailerScope::Project, ..)` at line 1113 | Avoids duplicating commit subject/trailer/timeout logic. |
| XML envelope body escape | Re-roll escape | `crate::owl::poll::event_attr_escape` + `event_body_escape` | Established at `src/owl/echo_commune.rs:75-79`. (NOTE: D-11 slices use raw bodies, but if planner picks attribute-bearing form, reuse the escapers.) |
| Cross-volume rename | Pure `fs::rename` | Mirror `src/common/tracked.rs:1578-1614` pattern: rename, on Err fall back to copy + remove | Established Phase 24 migration pattern; handles EXDEV on /tmp /home volume splits. |
| Liveness check | New pid-alive logic | `owlery::is_perch_online(id)` at line 405 | Already covers ready+pid+BUSY semantics. |
| cwd → project name | New git/folder resolver | `owlery::derive_current_repo_names()` at line 447 | Phase 24.1 D-09. First element = first-wins rule per D-08. |
| Front-matter strip | New parser | `strip_file_head_frontmatter` at `src/live/context.rs:506` (existing) | Reused by both writers. |
| Worker classification | id-regex only | `is_worker_perch_path(&Path)` (NEW) — combines path-parent + id-suffix | D-03 LOCKED. |

**Key insight:** Phase 25 is "wire it up" not "build it." Almost every primitive already exists; the work is mostly composition + a few new path-aware variants of existing predicates.

## Runtime State Inventory

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | • `agents/<id>/live_context.md` (Phase 24 D-13). • `agents/<id>/memformat.xml`. • `projects/<name>/<self_id>.md` (NEW, lazy-created). • `info.json` per perch (unchanged, no path field). • `.last-commune-epoch` per Self (path-independent). • Spool SQLite `.spool.db` under each perch's inbox (path-relative to perch dir). | None for nesting — all paths are derived at read-time from `perch_dir(id)` / `inbox_dir(id)` / `agent_worktree_path(id)`. After D-16 relocate, the moved psyche's spool reads via `inbox_dir(psyche_id)` which now resolves through the path-aware `perch_dir`. **Planner must verify:** `perch_dir(id)` itself returns the Self-perch path (`owlery/<id>/`), which is correct for Self. For psyche/workers, ALL call sites that currently do `perch_dir(psyche_id)` will return `owlery/<psyche_id>/` — i.e. the flat legacy path. Phase 25 must introduce `nested_perch_dir(parent, child)` AND audit every `perch_dir(psyche_id)`/`perch_dir(worker_id)` call site to swap to the nested form. See Pitfall #1. |
| Live service config | None — no external service registries. | N/A. |
| OS-registered state | Windows Task Scheduler: none. pm2/launchd: none. SPT detached wrapper PIDs tracked via `.psyche-wrapper-<id>.pid` files (`src/live/start.rs::psyche_wrapper_pid_file`) — file name embeds id but lives in `owlery/` root, NOT inside the perch dir. | None — wrapper PID files live outside the moved psyche dir. |
| Secrets/env vars | `$OWL`, `$LIVE` (auto-injected, value = `owl.exe live` path); `SPT_HOME` (state root override). None reference perch paths. | None. |
| Build artifacts / installed packages | `target/release/owl.exe` (rebuild required after `psyche.md` edit per CLAUDE.md `include_str!`). Plugin sync target: `~/.claude/plugins/cache/cplugs/spt/` (deploy script `docs/DEPLOY.ps1` handles). | Deploy will rebuild + sync. No package install. |

**The canonical question:** *After Phase 25 lands, what runtime systems still address the OLD flat perch path?* Answer: ZERO, IFF every `perch_dir(psyche_id)` / `perch_dir(worker_id)` call site in src/ is audited and migrated to the path-aware form. See Pitfall #1.

## Common Pitfalls

### Pitfall 1: Flat-only `fs::read_dir(&owlery)` call sites (D-04 ripple)

**What goes wrong:** Updating `enumerate_perches` to recurse covers the single call site at `src/live/pick_spec.rs:346`. But there are ~25 OTHER call sites that do `let entries = fs::read_dir(&owlery)` inline — they enumerate only Layer 1 and miss every nested perch. Phase 25 nesting silently breaks list, doctor, stop --all, cleanup, hook output, send, ring, resume, etc.

**Why it happens:** `enumerate_perches` was promoted in Phase 26-01 but most pre-existing call sites never migrated to it; they remained inline.

**Full list of inline `read_dir(&owlery)` call sites that need recursion treatment (or migration to `enumerate_perches`):**

| File | Line | Purpose |
|------|------|---------|
| `src/owl/cleanup.rs` | 21 | Stale perch cleanup |
| `src/owl/doctor.rs` | 117, 342 | Stale perches + nested doctor checks |
| `src/common/hook_output.rs` | 198, 231, 266 | Hook output for session start |
| `src/common/list_filter.rs` | 92 | **Shared list collector — fixing this fixes both $OWL list + $LIVE list** |
| `src/owl/hook_subagent_stop.rs` | 34 | Subagent cleanup |
| `src/live/list_psyches.rs` | 22 | `$LIVE list` |
| `src/owl/list_working.rs` | 11 | Worker perch list |
| `src/owl/list.rs` | 107 | `$OWL list` structured result variant (`list_result`) |
| `src/live/touch_loop.rs` | 15 | Touch ping cycle |
| `src/live/stop.rs` | 69, 111, 131, 149, 166 | `$LIVE stop` cascade |
| `src/owl/resume.rs` | 144, 274 | **Resume / dead-perch detection — D-04 LOCKED here per CONTEXT** |
| `src/owl/stop.rs` | 26, 44, 63, 159, 177, 195 | `$OWL stop --all` |

**How to avoid:** Two strategies; planner picks:

1. **Migrate to `enumerate_perches`.** Where the inline loop's behavior reduces to "(id, info, path) for each perch" — most cases. Slight refactor per site.
2. **Add a helper `walk_perches_recursive(owlery, |path, id, info| { ... })`** that takes a closure. Smaller diff but introduces a new abstraction.

Recommendation: option 1 — `list_filter::collect` is the highest-ROI single-site fix (covers both list flows + dedupes the predicate code). For sites with simple "iterate + check pid" patterns (stop.rs, cleanup.rs), inline the recursion bump. Use a new helper `read_dir_recursive_with_path(owlery_dir)` if pattern repeats more than 3 times.

**Warning signs:** Tests for `$OWL list` showing nested psyches/workers missing; doctor showing dead worker count not matching what's on disk; stop --all leaving nested dirs behind.

### Pitfall 2: psyche.md rebuild requirement

**What goes wrong:** Edit `psyche.md` for D-11 envelope teaching, run `cargo build --release`, deploy — but psyche.md changes don't take effect because the binary baked the OLD content via `include_str!`.

**Why it happens:** Cargo's incremental rebuild may not detect psyche.md as a dependency if it's not in `src/` or not referenced by `build.rs`. Per CLAUDE.md, `psyche.md` IS embedded via `include_str!` and requires rebuild.

**How to avoid:** Verify the rebuild detects psyche.md changes via `touch psyche.md && cargo build --release` and check the produced binary's strings. If incremental misses, planner adds `cargo:rerun-if-changed=psyche.md` to `build.rs` (or to wherever the include happens). Likely already wired — verify on first task.

**Warning signs:** Haiku continues to emit single-blob output (no `<live-context>` tags) after deploy.

### Pitfall 3: First-fire-of-agent — missing CURRENT_LIVE_CONTEXT

**What goes wrong:** First commune for a brand-new agent: `agents/<id>/live_context.md` doesn't exist. `fs::read_to_string` returns `Err`. D-12 prompt assembly explodes or inlines an empty/error string into the haiku prompt.

**Why it happens:** Phase 25 D-12 introduces a new read site at fire time. The existing context.rs read sites use `.ok()` chains; the new prompt construction in `echo_commune.rs` must do the same.

**How to avoid:** Match the existing `download_payload` pattern (line 441) — `if let Ok(content) = fs::read_to_string(&path)` with `unwrap_or_else(|_| "(none — first commune)".to_string())` fallback string. Test case: brand-new self_id with empty agent worktree.

**Warning signs:** Haiku output empty or contains literal Rust error strings.

### Pitfall 4: Cross-volume `fs::rename` failure (D-16 Windows specifically)

**What goes wrong:** D-16 psyche relocate: `fs::rename(legacy_flat, nested_target)`. Returns `Err` because `$SPT_HOME` is on D: but somehow `nested/` is on a junction-pointed volume, or temp/persistent split, or `%LOCALAPPDATA%` is symlinked. On Windows, this returns `ERROR_NOT_SAME_DEVICE`.

**Why it happens:** Real users have weird filesystem layouts; corporate VDIs often virtualize parts of AppData.

**How to avoid:** Mirror the `src/common/tracked.rs:1578-1614` pattern exactly — rename, on Err fall back to `fs::copy_dir_all`-equivalent + `fs::remove_dir_all`. Since `fs::copy` is file-only, the fallback for a directory is to iterate entries and copy each. Simpler: use a small `move_dir_with_fallback(src, dst)` helper. Alternatively: since the psyche perch dir is usually small (a few KB), a recursive copy is cheap.

**Best-effort posture** (CONVENTIONS): on hard failure, emit one stderr line `WARNING: failed to relocate psyche perch {src} -> {dst}: {err} (continuing with legacy path)` and return — boot continues, perch resolves at legacy flat location, D-04 enumeration still finds it.

**Warning signs:** Stderr warning appears on `$LIVE start`; `$OWL list` shows the legacy flat psyche perch instead of the nested one.

### Pitfall 5: D-20 sweep racing with worker spawn

**What goes wrong:** A worker is being spawned by another `$LIVE` instance or a subagent at the exact moment of D-20 sweep. The worker's perch dir is created but `info.json` isn't written yet. Sweep predicate "info.json absent AND mtime > 30s" guards this, but if a worker dir creation precedes info.json write by >30s (unusual but possible on slow filesystems), sweep removes a live spawn.

**Why it happens:** Worker spawn is multi-step: `create_dir(perch)`, `create_dir(perch/inbox)`, `write(info.json)`, `write(ready)`. Each step can pause; on slow disks the 30s grace can elapse.

**How to avoid:** 30s grace is the locked posture per CONTEXT D-20. Real defense is making worker spawn atomic-enough: write `info.json` before creating `inbox/` (so the predicate's "info.json exists" branch covers the mid-spawn window). Audit the worker spawn site (`src/owl/send.rs:67` ephemeral perch path; trace from there).

**Warning signs:** Workers vanishing mid-spawn under heavy multi-agent load. Mitigation: planner can add a `--no-sweep` flag to `$LIVE start` for debugging, or extend the grace to 60s.

### Pitfall 6: D-17 duplicate de-dupe ordering (nested-wins)

**What goes wrong:** After D-16 relocate, both `owlery/foo-psyche/` (legacy flat) AND `owlery/foo/nested/foo-psyche/` exist. `enumerate_perches` returns both. List/doctor show duplicate `foo-psyche` rows. Stop/send hit ambiguity.

**Why it happens:** D-16 relocate failed mid-move (e.g. copied to nested but couldn't delete legacy), or external state drift.

**How to avoid:** Add explicit de-dupe in `enumerate_perches`: collect into a `HashMap<String, (InfoJson, PathBuf)>` keyed by id; if the same id appears twice, keep the one with `path.parent.file_name == "nested"` (nested-wins per D-17). Doctor separately reports the dropped legacy entry as `stale-leftover` with auto-cleanup if its pid is dead. The auto-cleanup gate (pid dead) is essential — never racing a live process per Phase 18.4/18.5 binary handoff.

**Warning signs:** `$OWL list` shows duplicate ids. Doctor surfaces stale-leftover warning.

### Pitfall 7: psyche.md cargo rebuild cache (build.rs)

**What goes wrong:** `psyche.md` is included via `include_str!` from somewhere in `src/`. Cargo's dep-tracking may or may not re-trigger the include on `psyche.md` change.

**How to avoid:** Verify rerun. Grep for `include_str!.*psyche.md` and ensure a `cargo:rerun-if-changed=psyche.md` line exists in build.rs (if there is a build.rs) OR that the `include_str!` macro's path is relative to src/ — cargo tracks src/ globally. If `psyche.md` lives at repo root, it needs explicit rerun directive.

[CITED: CLAUDE.md says "psyche.md is embedded into the binary via `include_str!`. Changes require a rebuild." — implies the rebuild is the user's responsibility, suggesting auto-detection may not be guaranteed.]

### Pitfall 8: SessionStart hook auto-inheriting reshape

**What goes wrong:** D-15 says SessionStart's `<psyche-context>` block inherits the D-14 reshape "for free" because `download_payload_for_injection` calls `download_payload`. But: the SessionStart hook fires from the USER'S cwd (Claude Code's project root), not the wrapper's cwd. Different `derive_current_repo_names()` result, different `cwd_project`, possibly different project lookup.

**Why it happens:** `download_payload` reads `derive_current_repo_names()` at call time. Wrapper's cwd vs SessionStart's cwd usually agree (both = project root), but in edge cases (running owl from a subdir, SessionStart from monorepo root with workspaces) they diverge.

**How to avoid:** This is actually intended D-09 behavior — cwd-aware lookup happens at every call. If wrapper fires from cwd_A and SessionStart fires from cwd_B, the wrapper's commune writes `projects/A/<id>.md` and SessionStart's resume looks up `projects/B/<id>.md` (which doesn't exist → no project section). User-facing: starting Claude Code from a different cwd than where commune fired means no project section on resume. **Document as known D-09 strictness consequence.**

**Warning signs:** Project section appears in psyche-download CLI but not in SessionStart `<psyche-context>` even when on the same machine.

## Code Examples

(See Pattern 1–8 above for full code examples — verified via codebase Read.)

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Flat owlery: Self + Psyche + workers sibling | Nested: Self at owlery/<id>/; Psyche + workers at owlery/<id>/nested/<child>/ | Phase 25 (this phase) | Cleaner hierarchy; per-parent cascade; sweep scoping. |
| Single-blob psyche output | Two-slice envelope (live + project) | Phase 25 | Cross-project context separation. |
| `download_payload` = memformat + live_context.md only | + optional `projects/<cwd>/<id>.md` body | Phase 25 (D-14) | psyche-download becomes cwd-aware. |
| `is_worker_perch(id: &str)` id-regex only | `is_worker_perch_path(&Path)` path-aware | Phase 25 (D-03) | Disambiguates legacy-flat vs nested classification. |
| Lazy on-removal of worker perches only | + active sweep at `$LIVE start`/signoff | Phase 25 (D-20) | Eliminates daily orphan accumulation. |
| `parent_id` info.json proposal | Path-derived parent (no schema change) | Phase 25 (D-02) | Filesystem is single source of truth. |

**Deprecated/outdated:**

- `is_worker_perch(id: &str)` — kept for legacy flat coexist; consider `#[deprecated]` once all callers migrate to path form. CONTEXT D-03 LOCKED says drop "once all callers route through the path form" — eventual, not blocking Phase 25.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `psyche.md` rebuild auto-detection — `include_str!` may or may not trigger via cargo dep-tracking | Pitfall 2, 7 | First Phase 25 deploy ships old psyche.md content if rebuild doesn't catch. Verify with `cargo build --release && strings target/release/owl.exe \| grep <unique-new-string>`. |
| A2 | 30s grace for D-20 sweep info.json-missing branch | Pattern 6 | Too short = kills live spawns; too long = lets debris sit. Planner finalizes per CONTEXT D-20. |
| A3 | `fs::rename` works cross-dir-within-volume on Windows for non-empty dirs | Pitfall 4 | If not, copy+remove fallback kicks in. Phase 24 migration already exercises this; precedent is sound. |
| A4 | psyche perch dir contents have no absolute-path-bearing files | Pattern 5 / Runtime State Inventory | If any state file embeds absolute path (e.g. a future log-file path field in info.json), rename invalidates it. Phase 25 info.json schema unchanged per D-02 — verified no `path` field today. |
| A5 | First commune happens AFTER both `$LIVE start` sweep AND psyche relocate, so D-12 inline read can find live_context.md once it exists | Pattern 4 | If sequencing is reversed, first commune always reads `(none)` instead of inheriting prior state. Currently sequencing IS correct: relocate fires in start.rs before wrapper spawn; first commune fires inside wrapper. |
| A6 | `derive_current_repo_names().first()` matches Phase 24.1 `last_project_name` exactly | Pattern 8 | Two-name case: D-08 LOCKED to first-name-only. Phase 24.1 D-09/D-10 confirms first-wins ordering. Consistent. |
| A7 | Existing `parse_markers` at `src/live/wrapper/claude.rs:503` is the right shape to mirror for D-11 envelope extraction | Pattern 3 | If `parse_markers` does something more sophisticated than tag-find (e.g. nested marker tolerance), the D-11 parser should mirror it. Quick verify by reading that function. |

## Open Questions

1. **Should `is_psyche_perch` accept only path or both path + id?**
   - What we know: D-03 LOCKED path-aware form. CONTEXT "Claude's Discretion" allows planner to pick `&Path` vs `&PerchEntry`.
   - What's unclear: Some callers compute the id (e.g. for log strings) and never have the path. They'd need to reconstruct via `perch_dir(id)` or accept an id-only form.
   - Recommendation: Provide BOTH `is_psyche_perch_path(&Path)` (canonical) and `is_psyche_perch(id: &str)` (back-compat for legacy flat). Inline doc says "prefer path form; id form returns true only for legacy-flat siblings."

2. **Does psyche.md emit `<live-context>`/`<project-context>` always, or only when the new template is rolled out?**
   - What we know: Phase 25 ships the new template via psyche.md edit. Coexist is via fallback (Pattern 3): missing tags → whole payload to live slot.
   - What's unclear: Whether old binaries running against the new psyche.md (or vice versa) need any extra robustness.
   - Recommendation: Both directions are safe. New parser tolerates missing tags. Old parser was a whole-string append — fed new-style envelope text would write it literally into live_context.md (acceptable degradation; user re-runs after rebuild).

3. **`<psyche-stamp/>` on project file (D-14 Claude's Discretion)?**
   - What we know: D-14 LOCKED stamp on live_context.md only. CONTEXT says planner confirms.
   - What's unclear: Drift-detector behavior if the project file has independent edit history.
   - Recommendation: Stamp stays on live_context.md only. Project file is project-scoped — its drift IS the live-context's drift from the cwd-current state. No second stamp needed.

4. **Reuse `parse_markers` or build new `parse_two_slice`?**
   - What we know: `parse_markers` at `src/live/wrapper/claude.rs:503` handles `[COMMUNE]...[/COMMUNE]` brackets.
   - What's unclear: Whether it's a generic tag-bracket extractor or hardcoded to `[`/`]` delimiters.
   - Recommendation: New helper `parse_two_slice` — different delimiter (`<...>` XML-style) and different consumer. Don't over-couple.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust toolchain (cargo) | Build | ✓ | per Cargo.lock | — |
| git CLI | Phase 24 worktree creation | ✓ (verified Phase 24) | any 2.x | Phase 24 D-02 missing-git soft-fail already in place |
| ctx7 (Context7 CLI) | Documentation lookup | not relevant | — | All Phase 25 work is internal; no external library docs needed |
| psyche.md file | `include_str!` at build time | ✓ | — | — |
| Plugin marketplace clone (deploy) | DEPLOY.ps1 | external concern | — | manual install |

No missing dependencies. Phase 25 is pure internal Rust + already-installed git.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[test]` + `cargo test` |
| Config file | `Cargo.toml` (test deps), per-test-file modules |
| Quick run command | `cargo test --lib` |
| Full suite command | `cargo test` |

Golden tests at `tests/golden_owl.rs`, `tests/golden_live.rs` diff binary output against `tests/golden/` fixtures; excluded on Windows CI (fixtures captured via bash). Capture script: `scripts/capture_golden.sh` (bash-only).

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| NEST-PSYCHE-PERCH-01 | Psyche perch resolves at `owlery/<self>/nested/<self>-psyche/` after fresh start | integration | `cargo test --test handoff_integration nested_psyche_layout` | ❌ Wave 0 |
| NEST-WORKER-PERCH-01 | Worker perch spawned under Self at `owlery/<self>/nested/<self>-w<N>/` | integration | `cargo test --test native_owl nested_worker_layout` | ❌ Wave 0 |
| DOWNLOAD-CWD-01 | `download_payload` appends project section iff cwd_project file exists | unit | `cargo test --lib context::download_payload_appends_project_section` | ❌ Wave 0 |
| COMMUNE-PROJECT-ROUTE-01 | Commune two-slice envelope routes live → live_context.md, project → projects/<cwd>/<id>.md | unit + integration | `cargo test --lib echo_commune::two_slice_routes_correctly` | ❌ Wave 0 |
| MIGRATE-PERCH-01 | D-16 relocate moves legacy flat psyche to nested on `$LIVE start` | integration | `cargo test --test handoff_integration psyche_relocate_on_start` | ❌ Wave 0 |
| D-04 (enumerate recursion) | `enumerate_perches` returns both flat + nested | unit | `cargo test --lib owlery::enumerate_perches_recursive` | ❌ Wave 0 |
| D-03 (path predicate) | `is_worker_perch_path(&Path)` distinguishes flat vs nested | unit | `cargo test --lib owlery::is_worker_perch_path_*` | ❌ Wave 0 |
| D-11 (envelope parser) | Two-slice parse handles missing tags / nested whitespace | unit | `cargo test --lib parse_two_slice_*` | ❌ Wave 0 |
| D-12 (inline injection) | Haiku prompt contains CURRENT_LIVE_CONTEXT + CURRENT_PROJECT_CONTEXT | unit (via build_haiku_cmd inspection) | `cargo test --lib echo_commune::prompt_contains_current_blocks` | ❌ Wave 0 |
| D-13 (project worktree) | `commit_project_payload` materializes worktree + commits with Project trailer | unit | `cargo test --lib tracked::commit_project_payload_*` | ❌ Wave 0 |
| D-17 (dup resolve) | `enumerate_perches` returns nested entry when both flat + nested exist | unit | `cargo test --lib owlery::enumerate_perches_nested_wins` | ❌ Wave 0 |
| D-18 (tree render) | `$OWL list` indents nested children under Self parent | golden | `cargo test --test golden_owl list_tree_render` | ❌ Wave 0 (regenerate fixtures) |
| D-20 (sweep) | Sweep removes own offline workers; preserves online + cross-agent | unit + integration | `cargo test --lib owlery::sweep_own_orphaned_workers_*` | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test --lib` (~10s, no I/O-heavy integration)
- **Per wave merge:** `cargo test` (full suite, ~60s)
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] New unit tests in `src/common/owlery.rs` (path predicates, enumerate recursion, sweep)
- [ ] New unit tests in `src/owl/echo_commune.rs` (prompt assembly with CURRENT_* blocks, two-slice parser)
- [ ] New unit tests in `src/live/context.rs` (project section append)
- [ ] New unit tests in `src/common/tracked.rs` (commit_project_payload)
- [ ] New integration tests in `tests/handoff_integration.rs` (D-16 psyche relocate)
- [ ] New integration tests in `tests/native_owl.rs` (nested worker spawn, D-20 sweep)
- [ ] Regenerated golden fixtures for `tests/golden_owl.rs` + `tests/golden_live.rs` (list tree render, doctor output)
- [ ] **NO new framework install needed** — `cargo test` already runs.

**Note on Windows CI:** Golden fixtures captured via `scripts/capture_golden.sh` (bash-only). Reviewer running on Windows must either WSL-shell into the script or planner provides a PowerShell equivalent. Per existing convention (CI exclusion of goldens on Windows), the golden tests for tree render will not gate Windows CI; manual verification on Unix is the practice.

## Security Domain

Not applicable. Phase 25 is internal filesystem reshape + envelope parsing on trusted local output (haiku subprocess). No new network surface, no new user input, no auth/session/access-control changes. Path traversal: all path composition uses `Path::join` from validated id chars (existing `tracked::validate_id_chars` covers the project_name path — verify in Wave 1 that project names entering `project_worktree_path` route through the same validator).

| ASVS Category | Applies | Standard Control |
|---------------|---------|------------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | yes (path components) | `tracked::validate_id_chars` for project names entering `ensure_project_worktree` |
| V6 Cryptography | no | — |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Path traversal via crafted project name → write outside SPT_HOME | Tampering | `validate_id_chars` rejects `..`, `/`, `\` already (verified in `tracked.rs:149`). |
| Symlink in nested/ pointing outside owlery | Tampering | D-20 `remove_dir_all` honors symlinks per std behavior; mitigate by checking `path.is_symlink()` before removal (defensive). Low risk on Windows (user-created symlinks rare). |

## Sources

### Primary (HIGH confidence)
- Codebase Read of all touchpoints listed in Standard Stack table — verified file:line for every helper signature
- `.planning/phases/25-perch-nesting-psyche-workers-wire-psyche-download-to-forked-/25-CONTEXT.md` — all 21 decisions locked
- `CLAUDE.md` — psyche.md `include_str!` rebuild constraint, Windows-native + Unix portability

### Secondary (MEDIUM confidence)
- `.planning/codebase/TESTING.md` is STALE (claims "no tests exist") — actual test infrastructure is extensive at `tests/`. Phase 25 plans should treat TESTING.md as ignored; rely on actual test file inventory + `cargo test` for ground truth.

### Tertiary (LOW confidence)
- None — Phase 25 has no external-library-doc dependencies.

## Metadata

**Confidence breakdown:**
- Standard stack / touchpoints: HIGH — verified via codebase Read of every cited file:line
- Architecture patterns: HIGH — patterns extracted from existing precedent (Phase 24 migration for `fs::rename`+fallback, Phase 23 envelope for D-11 shape, Phase 30 D11 for `download_payload` append pattern)
- Pitfalls: HIGH for #1 (25 inline call sites enumerated); HIGH for #4 (precedent exists); MEDIUM for #2/#7 (psyche.md rebuild auto-detection — A1 assumption)
- D-12 first-fire fallback string: HIGH — pattern mirrors existing `.ok().and_then(...)` chains throughout context.rs
- D-20 grace window heuristic: MEDIUM — 30s number is locked by CONTEXT but real-world race testing not done

**Research date:** 2026-05-21
**Valid until:** 2026-06-21 (30 days for stable internal refactor; no external lib version drift to worry about)

---

## Wave Planning Hints (for the planner)

Suggested dependency graph (the planner finalizes wave assignment):

```
   Wave 1 (foundation)
   ┌───────────────────────────────────────────────────────┐
   │ • nested_perch_dir(parent, child) helper              │
   │ • is_worker_perch_path / is_psyche_perch_path         │
   │ • enumerate_perches recursion (D-04) + return type    │
   │   change to (id, info, path)                          │
   │ • Inline read_dir(&owlery) call site migrations       │
   │   (the 25 sites from Pitfall #1)                      │
   │ • commit_project_payload thin wrapper                 │
   │ • Unit tests for all of the above                     │
   └───────────────────────────────────────────────────────┘
            │                            │
            v                            v
   Wave 2 (lifecycle)             Wave 3 (payload reshape)
   ┌───────────────────┐         ┌─────────────────────────┐
   │ • D-16 relocate   │         │ • download_payload      │
   │   hook in start.rs│         │   project-section       │
   │ • D-16 mirror in  │         │   append (D-14/D-15)    │
   │   live_start_     │         │ • run_save / context_   │
   │   result          │         │   save_result project-  │
   │ • D-20 sweep      │         │   slice routing (D-10)  │
   │   helper +        │         │ • Two-slice envelope    │
   │   wiring in       │         │   parser (D-11)         │
   │   start.rs +      │         │ • signoff envelope      │
   │   signoff.rs      │         │   teaching              │
   │ • Tests           │         │ • Tests                 │
   └───────────────────┘         └─────────────────────────┘
                                              │
                                              v
                                   Wave 4 (psyche.md)
                                   ┌─────────────────────────┐
                                   │ • psyche.md prompt      │
                                   │   teaching the new      │
                                   │   envelope shape +      │
                                   │   D-10 taxonomy         │
                                   │ • D-12 inline current   │
                                   │   state injection in    │
                                   │   echo_commune.rs       │
                                   │ • Cargo rebuild check   │
                                   │ • Tests verify haiku    │
                                   │   prompt contains both  │
                                   │   CURRENT_* blocks      │
                                   └─────────────────────────┘
                                              │
                                              v
                                   Wave 5 (UX + docs)
                                   ┌─────────────────────────┐
                                   │ • Tree render in list.rs│
                                   │   (D-18)                │
                                   │ • Doctor D-17 dup +     │
                                   │   D-21 orphan count     │
                                   │ • Golden fixture        │
                                   │   regeneration          │
                                   │ • Integration tests for │
                                   │   end-to-end flow       │
                                   └─────────────────────────┘
```

**Parallelizable within waves:**
- Wave 1: unit tests for predicates, enumeration, and commit_project_payload can run in parallel after the API surface is committed.
- Wave 2: D-16 and D-20 share a code site (start.rs) but touch different functions — parallel-safe if planned as separate tasks.
- Wave 3: download_payload reshape, two-slice parser, and signoff envelope teaching are independent.
- Wave 4 is the only wave with a strict serial dependency: psyche.md edit → rebuild → test inline injection.

**Wave 0 (pre-flight):** Add the Wave 0 test scaffolds listed in Validation Architecture before Wave 1 implementation lands, so each new helper has a failing test ready to pass on commit.

---

*Phase 25 research complete. Planner can now produce PLAN files for each wave.*
