# Perch-Path Audit: Psyche/Worker Flat vs Nested — RESEARCH

**Researched:** 2026-05-23
**Domain:** SPT runtime — perch state on disk (psyche, worker, Self)
**Confidence:** HIGH (every claim verified by source + on-disk evidence)

## Summary

Phase 25 D-01 migrated psyche and worker child perches to a nested layout
(`owlery/<self>/nested/<child_id>/`). The migration is **partial and
systemic**: high-level lifecycle code (`live/start.rs` perch creation,
`wrapper/lifecycle.rs::cleanup`, `wrapper/mod.rs` ready-file checks,
`hook_subagent_start.rs::spawn_worker_perch`) writes nested correctly — but
**three lower-level layers continue writing every byte of psyche/worker
perch state to the FLAT path:**

1. **`src/owl/poll.rs`** — the per-iteration psyche poll subprocess (spawned
   by the wrapper every 8 minutes) calls `perch_dir`/`ready_file`/`info_file`
   directly on the psyche id. It re-materializes `info.json`, `ready`,
   `status`, `inbox/`, `spool.db` at the FLAT psyche path on every poll.
   This is the primary writer the user observed.
2. **`src/common/spool.rs::open_spool`** — composes `owlery.join(id)`
   inline. Every spool write to a worker or psyche id auto-creates the
   FLAT perch dir and drops `spool.db` there. This is why we have
   `todlando-w81..w87/` flat dirs containing only `spool.db` + `.owl-aware`
   — they were never spawned at the flat path; they were created as a
   side-effect of the first message routed to that worker id.
3. **`src/common/inbox.rs`** — `set_has_messages`, `set_idle_ready`,
   `write_to_inbox`, `drain_inbox` ALL use `perch_dir(id)` / `inbox_dir(id)`
   directly. Same auto-flat-creation side effect.

On-disk evidence (probed `%LOCALAPPDATA%\spt\owlery` at 18:21 with
`todlando` actively live): the flat `todlando-psyche/` contains 9 files,
6 of which were written in the last hour (`.idle-ready`, `status`,
`info.json`, `ready`, `wrapper-state.json`, `.has-messages`,
`spool.db`). The nested `todlando/nested/todlando-psyche/` contains
files dated 5/22 and 5/21 — STALE artifacts from the migration moment, no
active writer touches them. Same pattern for workers: 7 flat
`todlando-w8N/` dirs (all containing spool.db within the last 60min);
nested has only 2 of those workers, both with stale info.

The two surviving nested writes are: (a) `live/start.rs` creating the
nested perch with ready+info at LIVE-START time, (b) `wrapper/mod.rs`
checking the ready-file existence on the nested path every iteration —
which means **the wrapper's loop-exit predicate is reading a file that no
runtime writer ever touches after start.rs's initial creation**. Today
that file survives passively because nothing deletes it; if anyone ever
`rm`s the nested ready file (or `live stop`'s nested cleanup activates),
the wrapper will exit even though the psyche is alive. **Latent landmine.**

There are also five **Class E** call sites (reader on flat-only with no
fallback) that today work only because the writers happen to be dual-pathed
to flat. If a future plan flips ANY writer to nested-only — without
fixing readers in lockstep — these sites silently break:
`live/commune.rs:60/120` (online check), `live/signoff.rs:174/253`
(reachability gate), `live/touch_loop.rs:84` (PSYCHE_DEAD detector),
`live/boot_spine.rs:221` (PSYCHE_DEAD revival re-check), and
`live/stop.rs:18` (psyche perch teardown). The same paired-write +
flat-only-read pattern that the just-resolved `.more-done` debug caught
(commits c8a5535 / bc71575) exists in 5+ other places.

**Primary recommendation:** treat the current state as **flat is canonical
for psyche/worker runtime state, nested is a dead 25-D-01 leftover**. The
cleanup options are (a) finish the migration by routing
`spool.rs::open_spool` and `inbox.rs::set_has_messages` through
nested-aware path resolvers, (b) walk back D-01 for psyche/worker state
and treat nested as a legacy compatibility surface only. Either way, all
five Class E readers need to either route through path-aware variants
OR be explicitly documented as flat-canonical. Pick a direction before
writing code; don't grandfather the inconsistency.

---

## Section 1 — Inventory Table

Legend for `Class`:
- **A** = Self/listener (correct flat — id IS a top-level perch)
- **B** = Psyche writer (FLAT; should be nested per D-01)
- **C** = Worker writer (FLAT; should be nested per D-01)
- **D** = Reader with documented migration-window fallback (intentional dual-path)
- **E** = Reader without fallback (FLAT-only or NESTED-only; potential bug)
- **T** = Test fixture (irrelevant to runtime)

`Risk` is the consequence today (given flat-is-actively-written).

| File:Line | Function | Helper | Child kind | Class | Risk |
|---|---|---|---|---|---|
| **src/common/owlery.rs:153** | `perch_dir` defn | — | any | — | core helper |
| **src/common/owlery.rs:161** | `nested_perch_dir` defn | — | psyche/worker | — | core helper |
| **src/common/owlery.rs:194-205** | `inbox_dir`/`ready_file`/`info_file` | `perch_dir` | any | — | all delegate flat |
| **src/common/owlery.rs:240** | `last_commune_epoch_path` | `perch_dir` self_id | Self | A | fine (Self) |
| **src/common/owlery.rs:561** | `is_perch_online` | `perch_dir` | any id | E | falsely-offline for nested-only |
| **src/common/owlery.rs:680** | `sweep_own_orphaned_workers` (nested branch) | `perch_dir(self_id).join("nested")` | worker walk | D | correct (D-20 path-aware) |
| **src/common/owlery.rs:711** | `sweep_own_orphaned_workers` (legacy flat) | `owlery_dir` walk | worker | D | correct (D-20 legacy branch) |
| **src/common/spool.rs:12-18** | `open_spool` | `owlery.join(id)` inline | any id | **B+C** | **auto-creates flat psyche+worker perch dirs (smoking gun #2)** |
| **src/common/inbox.rs:11** | `drain_inbox` | `inbox_dir(id)` | any id | E | flat-only; never sees nested inbox |
| **src/common/inbox.rs:47** | `write_to_inbox` | `inbox_dir(id)` | any id | **B+C** | auto-creates flat inbox dir on any delivery |
| **src/common/inbox.rs:77** | `idle_ready_path` | `perch_dir(id)` | any id | **B+C** | poll writes `.idle-ready` flat |
| **src/common/inbox.rs:99-111** | `set/has/clear_has_messages` | `perch_dir(id)` | any id | **B+C** | `.has-messages` sentinel flat |
| **src/common/wrapper_state.rs:124** | `wrapper_state_path` (writer entry) | `perch_dir(psyche_id)` | psyche | **B** | **intentional dual-path — locked decision, documented** |
| **src/common/wrapper_state.rs:154** | `wrapper_state_path_resolved` (reader) | both | psyche | D | nested-first / flat-fallback |
| **src/common/hook_output.rs:69** | `read_session_cache` | `perch_dir(owl_id)` | Self | A | Self ids only |
| **src/common/hook_output.rs:179** | `patch_session_id` | `info_file(owl_id)` | Self/psyche | E | psyche call-path = flat-only |
| **src/common/listener.rs:62/89** | `info_file(id)` | `info_file(id)` | poll target (any id) | **B+C** | flat info.json updates from poll's BUSY/ALIVE writes |
| **src/owl/poll.rs:102-104** | `run` setup | `perch_dir`/`ready_file`/`info_file` | psyche when invoked as `_psyche-wrapper` poll | **B** | **THIS IS THE PRIMARY OBSERVED WRITER. Spawned by wrapper every poll iteration; rewrites all flat psyche state. Smoking gun #1.** |
| **src/owl/poll.rs:218** | status file | `perch.join("status")` | psyche | **B** | flat status |
| **src/owl/send.rs:13,34,103,151,187,190,62** | is_alive/check_alive/run/run_reply/send_result | `perch_dir`/`ready_file`/`info_file` | any target id | E | falsely-offline for nested-only psyche/worker |
| **src/owl/ring.rs:17/27/149/171/293** | ring/check_alive_for_ring | `ready_file/info_file/perch_dir` | any id | E | same as send |
| **src/owl/cleanup.rs:167,171** | cleanup-session | `info_file(id)`,`ready_file(id)` | enumerated paths | E | but cleanup uses its own L1+L2 walk, then uses id-resolvers — bug: id resolver is FLAT; saves: `path` variable bound from walk is used for `info_path` (line 52), only `id` is used downstream of soft_stop, so per-perch teardown sites get the WALKED path not the id-derived one. Net: reads OK via path, but `soft_stop_perch` interior uses path arg → OK. |
| **src/owl/echo_commune.rs:184/2247/2764** | `info_file(self_id)` and test fixtures | `info_file` | Self | A | Self id |
| **src/owl/hook_check.rs:141** | `.owl-aware` sentinel | `perch_dir(&owl_id)` | worker | **C** | **worker writer flat — written on first PreToolUse per subagent. Source of `.owl-aware` files in flat todlando-w8N/.** |
| **src/owl/hook_idle.rs:118** | parent-state read | `info_file(owl_id)` | Self | A | Self id |
| **src/owl/hook_idle.rs:136** | psyche-ready check | `ready_file(&psyche_id)` | psyche | E | **flat-only; today survives because poll.rs dual-writes flat ready** |
| **src/owl/hook_idle.rs:142** | `.more-done` sentinel write | `perch_dir(&psyche_id)` | psyche | **B** | **paired with echo_fire.rs reader (D); already-fixed pattern** |
| **src/owl/hook_subagent_start.rs:118** | `parent_perch = perch_dir(parent_id)` | parent_id | Self | A | parent is Self — flat correct |
| **src/owl/hook_subagent_start.rs:128** | worker spawn perch | `nested_perch_dir(parent_id, working_id)` | worker | — | **correct — only nested-aware writer for workers** |
| **src/owl/hook_subagent_start.rs:221** | `.working-counter` | `perch_dir(parent_id)` | Self | A | parent Self — flat correct |
| **src/owl/plugin_session_start.rs:265,326,942,946** | session-resume restore | `info_file/ready_file` | Self | A | Self perch rehydrate |
| **src/owl/poll.rs runtime** writes ready/info/inbox/spool flat for ANY id supplied. When called as `owl poll <psyche-id> --psyche` from the wrapper, all writes go flat. | | | | **B** | confirmed root |
| **src/owl/reboot.rs:10** | reboot | `perch_dir(id)` | Self | A | Self only |
| **src/owl/resume.rs:88** | resume | `perch_dir(id)` | Self | A | Self perch resume |
| **src/owl/setup.rs:11** | setup | `perch_dir(id)` | Self | A | Self only |
| **src/owl/stop.rs:131,235** | run_single/run_all | `perch_dir(id)` | any id (CLI user-supplied) | E | psyche/worker arg leaks nested |
| **src/owl/whoami.rs:19,46** | whoami | `perch_dir(owl_id)` | Self | A | Self perch |
| **src/live/boot_spine.rs:56-74** | spine+touch perch creation | `perch_dir`/`ready_file` | spine+touch | A | spine/touch are Self-level — flat correct |
| **src/live/boot_spine.rs:147** | spine ready check | `ready_file(spine_id)` | spine | A | spine = Self-level |
| **src/live/boot_spine.rs:221** | PSYCHE_DEAD revival re-check | `perch_dir(psyche_id)` | psyche | **E** | **systemic bug-shape: if writers ever flip nested-only, Spine never sees revived psyche** |
| **src/live/boot_spine.rs:230,245-246** | self-revive notification + cleanup | `ready_file(self_id)`, `cleanup_perch(psyche_id)` | mixed | A/**E** | psyche cleanup flat-only (line 246) |
| **src/live/boot_spine.rs:280** | `cleanup_perch` | `perch_dir(id)` | any | E | called for psyche above |
| **src/live/commune.rs:60,120** | psyche online check | `ready_file(&psyche_id)` + `send::is_alive` | psyche | **E** | **today works only because poll.rs dual-creates flat ready+info** |
| **src/live/fork.rs:50** | new_perch collision check | `perch_dir(new_id)` | NEW Self | A | Self-level |
| **src/live/mod.rs:26** | `is_known_perch` | `perch_dir(id).join("ready")` | Self ids (caller filters) | A | Self only |
| **src/live/pick_spec.rs:304** | `perch_cwd_matches_here` | `perch_dir(id).join("info.json")` | Self (callers filter `is_worker_perch`) | A | Self only |
| **src/live/signoff.rs:174,253** | psyche reachability gate | `ready_file(&psyche_id)` | psyche | **E** | **same shape as commune.rs — flat-only today** |
| **src/live/signoff.rs:233,286** | Self perch tear-down | `perch_dir(id)` | Self | A | Self id |
| **src/live/start.rs:274-275** | `relocate_legacy_psyche_if_needed` | both | psyche | D | source=flat, target=nested — D-16 migrator |
| **src/live/start.rs:344,524,763,834** | psyche perch creation | `nested_perch_dir(id, psyche_id)` + `inbox_dir_at`/`ready_file_at` | psyche | — | **correct (nested writer)** |
| **src/live/start.rs:369-371,819-821** | Self perch creation | `perch_dir(id)` | Self | A | Self id |
| **src/live/start.rs:393,785** | legacy psyche collision pre-check | `perch_dir(&psyche_id)` | psyche | D | reader of flat for migration; documented |
| **src/live/stop.rs:18** | `kill_psyche_wrapper` cleanup target | `perch_dir(&psid)` | psyche | **E** | **wrapper writes ready/info nested-AND-flat (via poll subprocess); this only cleans flat. Asymmetric vs `wrapper/lifecycle.rs:127` which cleans nested.** Risk: nested psyche perch state survives `live stop`, then re-found by `enumerate_perches` as a ghost. |
| **src/live/touch_loop.rs:84** | Touch psyche-liveness scan | `perch_dir(psyche_id)` | psyche | **E** | **scan_live_psyches already returns the perch_path via enumerate_perches but the for-loop discards it and re-resolves flat. Same shape as the `.more-done` reader bug.** |
| **src/live/touch_loop.rs:121,127** | Spine ready + Touch self-cleanup | `ready_file/perch_dir` | Self-level | A | spine/touch = Self-level |
| **src/live/wrapper/echo_fire.rs:143-146** | `.more-done` reader | nested+flat | psyche | D | **already fixed (c8a5535 / bc71575) — pattern reference for all other Class E sites** |
| **src/live/wrapper/echo_fire.rs:195** | Self info.json | `info_file(&self.self_id)` | Self | A | Self id |
| **src/live/wrapper/lifecycle.rs:29** | handoff state read | `wrapper_state_path_resolved` | psyche | D | nested-first / flat-fallback |
| **src/live/wrapper/lifecycle.rs:103** | rehydration state write | `write_wrapper_state(&psyche_id, …)` → flat | psyche | **B** | **intentional per Plan 25.2-01 Task 3 Step 7; comment is explicit** |
| **src/live/wrapper/lifecycle.rs:127** | wrapper self-cleanup | `nested_perch_dir(self_id, psyche_id)` | psyche | — | **correct (nested cleanup)** — but asymmetric vs `live/stop.rs:18` |
| **src/live/wrapper/claude.rs:201** | `write_wrapper_state` after init_session | `wrapper_state_path(psyche_id)` → flat | psyche | **B** | **intentional dual-path** |
| **src/live/wrapper/mod.rs:1168-1171,1243-1246,1421-1423** | ready-file loop-exit check | `ready_file_at(nested_perch_dir(self, psyche))` | psyche | — | **correct nested reader — but the file is currently only written by start.rs at boot, never refreshed. Risk: if anyone unlinks the nested ready file, wrapper exits while alive.** |
| **src/live/wrapper/mod.rs:1685** | handoff state write | `perch_dir(psyche_id).join("wrapper-state.json")` → flat | psyche | **B** | intentional dual-path |
| **src/live/wrapper/orphan.rs:59,142** | Self info read + Self perch | `info_file/perch_dir(self_id)` | Self | A | Self id |
| **src/live/wrapper/orphan.rs:244** | INIT_SIGNOFF dispatch ready check | `ready_file_at(nested_perch_dir)` | psyche | — | correct nested reader |
| **src/owl/doctor.rs:1046, 1296, 1320–1472** | various test/seed fixtures | both | mixed | T | test fixtures — irrelevant |
| **src/common/tracked.rs:1418, 3291, 3553** | `info_file(agent_id)` reads (perch-side fallback) + test | `info_file` | Self | A | Self perch reads only |

### Subset cross-check by "id-shape" (what the helper sees at runtime)

| Helper call shape | Real id at runtime | Layout helper actually writes/reads | Required by D-01? |
|---|---|---|---|
| `perch_dir(id)` where id is `<owl>` (Self) | `dunsen`, `todlando`, … | FLAT | YES (Self is Layer 1) |
| `perch_dir(id)` where id is `<self>-psyche` | `todlando-psyche` | FLAT | NO — D-01 says nested |
| `perch_dir(id)` where id is `<self>-w{N}` | `todlando-w87` | FLAT | NO — D-01 says nested |
| `perch_dir(id)` where id is `<self>-touch` or `<self>-spine` | `dunsen-touch`, `dunsen-spine` | FLAT | YES — touch/spine are sibling Self-level perches |

---

## Section 2 — Confirmed B/C Regressions (Paired-Reader Analysis)

These are the actionable bugs. Each has a writer + reader pair where the
writer goes one place and the reader looks somewhere else (or could fail
to in a future state). Cross-referenced to the just-fixed `.more-done`
pattern (`src/live/wrapper/echo_fire.rs:143-146`) which is the reference
fix shape.

### B/C-1 — Psyche poll subprocess writes everything FLAT (primary)

**Writer:** `src/owl/poll.rs:127-222` invoked as `owl poll <psyche-id>
listen --psyche` from `wrapper/mod.rs::poll_psyche`. Writes:
- `info.json` (line 194) — overwritten every poll iteration
- `ready` file (line 142) — recreated if missing
- `status` file (line 219)
- `inbox/` dir (line 129)
- `spool.db` (via the drain at line 235 → `spool::drain_non_deferred_with_metadata`)
- `.idle-ready` (via `inbox::set_idle_ready` in subsequent code)

ALL at FLAT `owlery/<psyche-id>/`.

**Paired readers expecting nested:**
- `wrapper/mod.rs:1168` `ready_file_at(nested_perch_dir(self,psyche)).exists()` — this is the wrapper's loop-exit predicate. It reads nested, never gets refreshed by the poll subprocess. Currently survives only because start.rs initially writes the nested ready file ONCE at boot and nothing deletes it.
- `enumerate_perches` (with D-17 dedupe-nested-wins) iterating Layer 1 + Layer 2: for `todlando-psyche` BOTH paths exist; nested wins → callers see the **stale** 5/22-dated info.json, NOT the fresh 6:11PM flat one. **This is a real bug for any consumer that needs the live session_uuid.**

**Cross-reference:** same shape as `.more-done` — writer at one path,
reader at the other.

**Fix surface:** option A — make `poll.rs` accept an optional parent_id so
when invoked as `_psyche-wrapper` it writes to nested. Option B — flip the
nested readers (lines 1168/1243/1421) to `wrapper_state_path_resolved`-style
dual-path readers, then explicitly document poll.rs as flat-canonical.

### B/C-2 — Spool auto-creates flat perch dirs for any id

**Writer:** `src/common/spool.rs:12-18` `open_spool(id, owlery)` →
`owlery.join(id)` + `create_dir_all`. Called from:
- `spool_message`/`spool_message_deferred`/`drain_one`/`drain_all*`/`peek_all`/`mark_delivered`
- Used by `send::run`, `send::deliver_body`, `commune::run`, `poll::run`,
  `new_alarm::run`, etc.

When the target id is a worker (`<self>-wN`) or psyche (`<self>-psyche`),
this creates the FLAT perch dir as a side effect — even if the worker was
properly spawned nested via `spawn_worker_perch`. This is why `todlando-w81
… w87/` flat dirs exist on disk (containing only `spool.db` + `.owl-aware`)
despite `spawn_worker_perch` writing only nested.

**Paired readers expecting nested:** none today (spool helpers are all
flat-only — readers and writers agree). But `enumerate_perches::push_if_perch`
silently skips dirs without `info.json`, so a flat spool-only dir is
invisible — it just clutters the disk and confuses operators reading the
owlery contents.

**Fix surface:** plumb parent_id into spool helpers, OR add a path-aware
`open_spool_at(path)` and have callers compose the right path BEFORE
spool entry. Heavy refactor; touches every spool call site.

### B/C-3 — Inbox sentinels auto-create flat perch dirs

**Writer:** `src/common/inbox.rs:99` `set_has_messages(id)` and `:78`
`idle_ready_path(id)` both `perch_dir(id).join(...)`. Called from spool
inserts (`spool.rs:63,79`) and from `inbox::write_to_inbox`.

Same shape as B/C-2.

**Fix surface:** same as B/C-2.

### B/C-4 — `.owl-aware` worker sentinel written flat

**Writer:** `src/owl/hook_check.rs:141` —
`owlery::perch_dir(&owl_id).join(".owl-aware")` where `owl_id` is the
worker id returned from `find_working_perch_with_parent`.

**Paired reader:** `src/owl/hook_check.rs:142` `if !sentinel.exists()` —
same hook, same flat path. **Internally consistent (no regression),** but
this is the writer that creates the flat worker dir as soon as a subagent
makes its first tool call. Today's `todlando-w8N/.owl-aware` files come
from this site.

**Fix surface:** compose via the perch_path returned from
`find_working_perch_with_parent` (the tuple already includes `_perch_path`
in the underlying `enumerate_perches` call but it's discarded — see
`hook_output.rs:218` — bubble it up).

### B/C-5 — `.more-done` sentinel writer still flat (already-known)

**Writer:** `src/owl/hook_idle.rs:142` — flat. **Reader is now D-class
(echo_fire.rs:143-146).** This is the resolved pattern; included here for
completeness and to mark it as the canonical reference shape.

### B/C-6 — wrapper-state.json writers FLAT (intentional, documented)

**Writers:** `wrapper/claude.rs:201`, `wrapper/lifecycle.rs:103`,
`wrapper/mod.rs:1685` — all explicitly chosen flat for binary-handoff
compatibility. Comments cite Plan 25.2-01 Task 3 Step 6/7 + Q2 RESOLVED.
**Reader is D-class via `wrapper_state_path_resolved`.** Not a
regression — but worth noting that the migration window has now extended
for several major versions; the planner may want to decide whether to
finally retire the flat-writer surface.

---

### Class E Bugs (Reader flat-only, no fallback)

These work today only because flat is dual-written. If ANY writer ever
flips to nested-only, these silently break. Each needs a paired-reader
fix following the `echo_fire.rs::pick_fresher_more_done` shape.

| # | Site | Reads | Failure mode |
|---|---|---|---|
| E-1 | `src/live/commune.rs:60, 120` | `ready_file(&psyche_id)` + `send::is_alive(psyche_id)` | commune reports NO_PERCH for nested-only psyche |
| E-2 | `src/live/signoff.rs:174, 253` | `ready_file(&psyche_id)` | signoff silently skips INIT_SIGNOFF delivery to nested-only psyche |
| E-3 | `src/live/touch_loop.rs:84` | `perch_dir(psyche_id)` for `get_pid_from_info` | Touch always classifies nested-only psyches as dead, floods Spine with PSYCHE_DEAD |
| E-4 | `src/live/boot_spine.rs:221` | `perch_dir(psyche_id)` revival re-check | Spine never sees a revived nested-only psyche; spurious "Your Psyche has died" |
| E-5 | `src/live/stop.rs:18` | `perch_dir(&psid)` for soft_stop | `live stop` leaves nested psyche transient state intact; subsequent `enumerate_perches` sees a ghost |
| E-6 | `src/owl/send.rs:13,34,62,103,151,187,190` | `is_alive`/`is_perch_exists`/`ready_file` | every send to a nested-only target fails NO_PERCH |
| E-7 | `src/owl/ring.rs:17,27,149,171,293` | `ready_file/info_file/perch_dir` | ring scans miss nested-only perches |
| E-8 | `src/owl/cleanup.rs:167,171` | `info_file/ready_file(id)` inside the L1+L2 walker | cleanup-session per-id paths bypass the walked `path`; saves: the immediate downstream `soft_stop_perch` call takes `&path` not `&id`, so this is benign **today** but fragile |
| E-9 | `src/common/wrapper_state.rs` reads work — but **the equivalent SQLite/inbox infra is flat-only with no resolver counterpart** | n/a | systemic |

---

## Section 3 — On-Disk Evidence (active live agent: `todlando`)

Probed at 2026-05-23 18:21 PT. (Helper script saved at
`.planning/quick/.../probe.ps1` for re-run.) Raw output in
[probe.ps1.txt below — abbreviated highlights]:

### todlando-psyche (FLAT) — every file fresh, active writer

```
DIR: …\owlery\todlando-psyche  dir-mtime=2026-05-23 18:18:13
    2026-05-22 16:54  <DIR>  inbox            (created at first boot; rarely re-touched)
    2026-05-23 17:18  0      .has-messages    (FRESH, ~1h)
    2026-05-23 18:11  0      .idle-ready      (FRESH, ~10min)
    2026-05-22 16:55  0      .more-done       (stale)
    2026-05-23 18:11  315    info.json        (FRESH — written by poll.rs every iter)
    2026-05-23 17:18  0      ready            (FRESH)
    2026-05-23 17:18  36864  spool.db         (FRESH — WAL active)
    2026-05-23 18:11  44     status           (FRESH — poll.rs status line)
    2026-05-23 17:18  139    wrapper-state.json (FRESH — claude.rs:201 publish)
```

### todlando/nested/todlando-psyche (NESTED) — fixtures only, no live writer

```
DIR: …\owlery\todlando\nested\todlando-psyche  dir-mtime=2026-05-23 17:18:21
    2026-04-19 04:06  <DIR>  inbox            (one month old)
    2026-05-22 03:28  316    info.json        (one day old — start.rs nested initial write)
    2026-05-23 17:18  0      ready            (~1h — refreshed by start.rs at last boot? See below)
    2026-05-21 17:54  12288  spool.db         (two days old)
    2026-05-22 03:15  44     status           (one day old)
    2026-05-22 01:55  114    wrapper-state.json (one day old)
```

The `ready` file at the nested path was refreshed at 17:18 — coinciding
with the LAST `$LIVE start` (or the post-handoff publish via
`start.rs:524 / 834`). The wrapper's loop-exit predicate at
`wrapper/mod.rs:1168` is reading THAT file. Nothing in the steady-state
poll loop refreshes the nested ready file. **Survives only because no
code path deletes it.**

### Workers — flat dominant, nested fragmentary

Flat (7 dirs, last hour activity):
```
todlando-w81/  .owl-aware (17:28)  spool.db (17:28)
todlando-w82/  .owl-aware (17:29)  spool.db (17:29)
todlando-w83/  .owl-aware (17:48)  spool.db (17:48)
todlando-w84/  .owl-aware (17:52)  spool.db (17:52)
todlando-w85/  .owl-aware (17:57)  spool.db (17:57)
todlando-w86/  .owl-aware (17:59)  spool.db (17:59)
todlando-w87/  .owl-aware (18:04)  spool.db (18:04)
```

These flat dirs have NO `info.json` and NO `ready` file —
`enumerate_perches::push_if_perch` skips them silently, so they don't
appear in `live list`. They are pure side-effect of hook_check.rs:141
(`.owl-aware`) + spool.rs:12-18 (`spool.db` autocreate).

Nested (2 dirs, w85 and w87 only):
```
todlando/nested/todlando-w85/  info.json (17:57)  ready (17:57)  inbox/  — fully formed nested perch
todlando/nested/todlando-w87/  info.json (18:04)  ready (18:04)  inbox/  — fully formed nested perch
```

w81-w84 and w86 have NO nested counterpart. So `spawn_worker_perch` only
fired (or only succeeded) for w85 and w87 — the other 5 workers got their
flat dirs from spool/inbox auto-create paths WITHOUT a properly registered
perch ever existing. Implication: those subagents never got
`spawn_worker_perch` called for them (or it failed silently). **Worth
investigating as a separate issue** — Open Question 1.

### Other agents

- `doyle` (offline, last started 5/22): has `nested/doyle-psyche/` with
  matching flat counterpart from before the most recent migration. No
  active writer.
- `webber` (offline): nested-only `nested/webber-psyche/`. **Pure nested
  case** — confirms the migration DID land for some agents at some point;
  current writes lost the nested-aware shape.
- `dunsen` (offline, last started 5/23 17:17): flat `dunsen/` Self-level
  only. No psyche dir at either layer (psyche was cleaned up by the soft
  stop).

---

## Section 4 — Recommended Fix Surface (per site)

The choice is **architectural** and needs operator input — see Section 5
Open Questions. Two coherent end-states:

### Option ALPHA — finish D-01 (nested-canonical)

Make every writer go nested for psyche/worker ids; keep flat as
legacy-fallback only.

| Site | Recommended action |
|---|---|
| `owl/poll.rs:102-222` | accept `--parent <self_id>` flag from wrapper; when set, resolve all paths via `nested_perch_dir(parent, id)` + `*_at` resolvers. Wrapper at `mod.rs:1527` adds the flag. |
| `common/spool.rs::open_spool` | introduce `open_spool_at(perch_path)` ; resolve via parent-aware shim. Caller sites in send/commune/poll thread parent id through. |
| `common/inbox.rs` | parallel `inbox::*_at(perch_path)` surface; callers thread perch_path. |
| `hook_check.rs:141` | use perch_path from `find_working_perch_with_parent` (extend that helper to return perch_path) |
| `wrapper_state.rs:124` writers | flip to nested ONCE all live wrappers on disk have advanced past pre-25.2 binaries (operator decision: are pre-25.2 wrappers still in flight?) |
| Class E sites E-1…E-7 | adopt the `wrapper_state_path_resolved` / `pick_fresher_more_done` dual-path reader pattern |
| `live/stop.rs:18` | clean BOTH flat and nested psyche perch dirs (symmetric with wrapper/lifecycle.rs:127) |
| `live/touch_loop.rs:84` | use the `_perch_path` already returned by `enumerate_perches` instead of re-resolving by id |
| `live/boot_spine.rs:221` | path-aware via `enumerate_perches` lookup by id-match |

### Option BRAVO — walk back D-01 (flat-canonical)

Treat flat as the canonical layout for psyche+worker runtime state and
nested as a legacy compatibility surface. Much smaller code change.

| Site | Recommended action |
|---|---|
| `live/start.rs:344,524,763,834` | flip back to `perch_dir(psyche_id)` for psyche perch creation. Drop the `relocate_legacy_psyche_if_needed` helper as no-op. |
| `wrapper/mod.rs:1168, 1243, 1421` | flip ready-file checks back to flat. Today's nested-reader is reading a passive fixture. |
| `wrapper/lifecycle.rs:127` | flip cleanup to `perch_dir(psyche_id)` (symmetric with stop.rs:18 which already does flat). |
| `wrapper/orphan.rs:244` | flip to flat. |
| `wrapper/echo_fire.rs:143-146` | pick_fresher logic still useful for the migration window; eventually drop the nested branch. |
| `hook_subagent_start.rs::spawn_worker_perch` | flip to `perch_dir` writer; drop nested counterpart. Workers stay flat forever. |
| `enumerate_perches` | drop the Layer 2 walk; psyche/worker classification by id-suffix only. |
| `sweep_own_orphaned_workers` | drop the nested branch (still keep flat sweep). |
| `live/doctor.rs` test fixtures | update to flat-only expectations. |

This option is LESS WORK and matches the **observed** runtime behavior.
The cost is reverting a documented architectural decision (D-01) without
satisfying its original motivation (whatever that was — see Open Q 4).

### Option CHARLIE — minimal patch (status quo, fix E-class only)

Don't pick a direction; just patch the 5 Class E reader bugs (E-1…E-5)
with `pick_fresher`-style dual-path readers. The system stays
self-inconsistent but the readers stop being landmines.

| Site | Action |
|---|---|
| `commune.rs:60,120` | new helper `psyche_live_check_dual(self_id, psyche_id) -> bool` that probes both paths |
| `signoff.rs:174,253` | same helper |
| `touch_loop.rs:84` | use existing `_perch_path` from `enumerate_perches` |
| `boot_spine.rs:221` | call `enumerate_perches`, find id match, use its path |
| `stop.rs:18` | call `enumerate_perches` to find ALL paths for `psid`, sweep each |

This is the smallest-surface fix and matches the `.more-done` precedent.
Recommended if the user wants stability without architectural commitment.

---

## Section 5 — Open Questions for the Planner

1. **W85/W87-only-nested mystery (Section 3):** Why do only w85 and w87
   have fully-formed nested perches when w81-w84 and w86 do not? Possible
   explanations: (a) `spawn_worker_perch` only fires on first subagent
   tool call, and those workers spawned but never made tool calls; (b)
   silent fs error on `create_dir_all` for the others; (c) different
   subagent type (e.g., bash background vs Agent tool). Worth checking
   the wrapper log for w81-w84.

2. **What was D-01's original motivation?** Read 25-RESEARCH /
   25-CONTEXT.md to understand why nested was chosen. If the motivation
   was "single owner of psyche state, easy cleanup on Self exit" —
   Option BRAVO defeats it. If the motivation was "namespacing /
   collision avoidance for multi-self deployments" — Option ALPHA is
   the only finish. The user's instinct ("I'm seeing flat psyche
   perches still getting created") strongly implies they want ALPHA
   completion, not BRAVO revert.

3. **Are pre-25.2 wrapper binaries still in flight?** The
   `wrapper_state.rs:124` flat-writer is locked open "for the migration
   window." If every running wrapper is post-25.2, the migration window
   can close (flip writers to nested-or-only, plus a one-shot
   nested-or-flat reader for the transition). If pre-25.2 wrappers can
   still hand-off in, the flat writer must stay.

4. **stop.rs:18 vs wrapper/lifecycle.rs:127 asymmetry:** the wrapper
   cleans nested on exit; `live stop` cleans flat. Was this intentional?
   In the BRAVO end-state both should be flat; in ALPHA both should
   walk both (or `live stop` should compose the nested path explicitly).

5. **Spool path correctness — is spool data a "perch property" or a
   "perch-id property"?** If you accept the latter, flat is correct
   forever and the nested-side cleanup must include moving any nested
   spool.db back to flat (or vice versa). If you accept the former
   (each perch owns its own spool), the spool path must follow the
   perch path. **This is the architectural keystone.** Resolving Q5
   resolves Q2 by implication.

6. **Should `enumerate_perches` skip flat-spool-only dirs?** Today they
   are silently skipped (no info.json) but the `todlando-w8N/` clutter
   confuses manual inspection. A `live list --verbose` or
   `live doctor` extension could report "X flat dirs without
   info.json — pure spool artifacts" so operators stop guessing.

7. **Latent ready-file landmine in wrapper/mod.rs:1168:** the wrapper's
   loop-exit predicate reads a file that nothing actively maintains.
   Today it survives only because nothing deletes it. ANY cleanup that
   removes the nested ready file (Option BRAVO's `enumerate_perches`
   refactor, or a future bug-fix that "tidies" nested) will kill live
   wrappers. Worth a regression test pinning the invariant.

---

## Sources

- **Primary (HIGH):** code-read of every cited file in `src/` against
  the working commit (status: bumped to v1.11.8); on-disk filesystem
  probe via `probe.ps1` against `$env:LOCALAPPDATA\spt\owlery`.
- **Reference fix shape (HIGH):** commits `c8a5535`, `bc71575`
  (`.more-done` writer/reader dual-path resolution).
- **Architectural locked decisions (CITED):** Plan 25.2-01 Task 3 Step
  6/7 (`wrapper_state.rs:124` comment), Phase 25 D-01 / D-16 / D-17 / D-20
  (cited inline at `start.rs`, `wrapper/lifecycle.rs`, `owlery.rs::dedupe_nested_wins`,
  `owlery.rs::sweep_own_orphaned_workers`).
- **Just-resolved debug:** `.planning/debug/resolved/more-done-sentinel-not-written.md`
  (referenced but not opened — confidence based on user's framing of the question).

## Metadata

**Confidence breakdown:**
- Inventory: HIGH — every cited line was opened and read in context, not grep-snipped.
- B/C-1 root cause (poll subprocess): HIGH — code read + on-disk mtime evidence aligns perfectly.
- B/C-2/3 (spool/inbox auto-flat): HIGH — code is unambiguous, on-disk evidence matches.
- Class E enumeration: HIGH — readers are flat-only by inspection.
- Recommendation (ALPHA vs BRAVO vs CHARLIE): MEDIUM — depends on Open Q 2/3/5 which require operator input.

**Research date:** 2026-05-23
**Valid until:** until any of the listed files change.
