---
status: diagnosed
trigger: "doyle sessions log seal failed + empty psyches/tracked dir + missing signoff context (3 symptoms suspected related)"
created: 2026-05-22T00:00:00Z
updated: 2026-05-22T12:30:00Z
mode: diagnose_only
---

## Current Focus

hypothesis: CONFIRMED — Stale 0-byte `index.lock` at `seed/worktrees/doyle/index.lock` blocks every seal commit. Two follow-up agents independently re-verified.
test: Re-checked smoking gun + user addendum claims about restructure / projects/ / wrapper-state-path.
expecting: User's "tracked/ has staged-deletes + 3 untracked dirs" status is COSMETIC (legacy ghost `tracked/.git/` from Phase 23, dormant since May 20). Real damage is the per-worktree lockfile.
next_action: (diagnose_only — no fix applied)

## Re-verification by follow-up agent (2026-05-22T12:45:00Z)

Independent agent re-investigated and confirms PRIOR REPORT IS CORRECT. Key supplements / addendum to prior report:

- **User's "20 staged deletes + 3 untracked dirs" finding** = the GHOST repo at `$LOCALAPPDATA/spt/psyches/tracked/.git/`, NOT the Phase 24 seed. This is a NON-bare Phase 23-era `git init` left in place per RESEARCH Pitfall 6 ("legacy `.git/` preserved in place for forensic value"). Last commit `4bed364 doyle g47 #37` is from May 20 23:07 by `Reavo End <decidel@gmail.com>` (operator identity, NOT the Phase 24 `spt-bootstrap` synth). `src/live/context.rs:331 git_commit_context` is the retired writer — currently a NO-OP. Nothing in current code reads or writes `tracked/.git/`. The "staged deletes" status reflects the OLD repo's stale index relative to the worktree post-migration. SAFE to delete `rm -rf $LOCALAPPDATA/spt/psyches/tracked/.git` with zero impact on live code (purely cosmetic cleanup).

- **agents/ + seed/ "untracked" in tracked/.git** is because Phase 24 NEVER added them to the ghost index (it deliberately stopped writing to that repo). They are properly tracked by the NEW `seed/`-rooted bare repo via `git worktree`. Not a missed `git add` step.

- **`projects/` missing** = lazy materialization (Phase 24 D-16) — first call to `commit_project_payload` fires `ensure_project_worktree("claude_skill_owl")`. No defect. Phase 25 nested-perch landed before Phase 25 two-slice envelope skill content; project worktree will appear naturally on the next two-slice commune/signoff. Independent of the seal failure.

- **wrapper-state.json path agreement** = both wrapper writer (`src/live/wrapper/lifecycle.rs:23`, `src/live/wrapper/claude.rs:172`, `src/live/wrapper/mod.rs:1100`) AND start.rs/signoff.rs readers (`src/live/start.rs:597`, `src/live/signoff.rs:197`) resolve via `wrapper_state_path` → `owlery::perch_dir(psyche_id)` → FLAT top-level path `owlery/doyle-psyche/wrapper-state.json`. NO path mismatch. The "missing or empty session_uuid after 2000ms" warning fires when the listener's 8×250ms=2000ms retry budget elapses before the wrapper's `init_session` (which runs `claude -p` — a remote LLM round-trip that easily takes 5-15s cold) writes wrapper-state.json. Pure timing race.

- **`live_context.md` IS populated** (independently verified — file head reads "Generation 4 spawn. Started 2026-05-22. Prior context absorbed."). User's "live_context.md missing last signoff content" report is likely a content-richness perception ("the last signoff didn't carry the full story I expected") rather than a missing-file or stale-file defect. Per signoff.rs:94-141 the file write precedes the git commit, so even if the commit fails on stale index.lock, the file content survives. This symptom is unrelated to the seal failure.

- **doyle gen-4 boot row IS present in sessions.log** (line 1: `{"ts":"2026-05-22T03:26:54Z","session_uuid":"22222222-eeee-ffff-1111-222222222222","trigger":"boot"}`). Note: that placeholder-looking UUID `22222222-...` suggests a test/fallback was used at some prior point, but it IS a row. Sessions.log has 9 total rows, all from a single accumulated window since the last successful seal.

## Bug count: ONE primary bug, TWO non-bugs.

PRIMARY BUG (Symptom 1 — seal failure):
- Single root cause: stale `index.lock` at `psyches/tracked/seed/worktrees/doyle/index.lock` (0 bytes, mtime May 21 23:40).
- Code gap: `ensure_worktree` fast-path at `src/common/tracked.rs:362-366` does NOT check for orphan index.lock files. Only `worktree prune` runs (line 184-188 and 351-355), and `worktree prune` does NOT clean per-worktree `index.lock` artifacts.
- Manual unblock: `rm $LOCALAPPDATA/spt/psyches/tracked/seed/worktrees/doyle/index.lock` — next gen boundary will then succeed.
- Code fix: add stale-index.lock probe + best-effort remove in `ensure_worktree` fast-path (algorithm + suggested location laid out in "Proposed Fix" section above).
- Suggested sweep: same probe across all per-worktree admin dirs at `seed/worktrees/*/index.lock` — any agent that crashed mid-commit since the last clean boot is also blocked.

NON-BUG #1 (Symptom 2 — "psyches/tracked/projects/ missing"):
- By-design lazy materialization. Will create on next two-slice commune/signoff with `<project-context>` slice. Phase 24 D-16 contract.
- The "20 staged deletes + 3 untracked dirs" in `tracked/.git` is the dormant Phase 23-era ghost repo; cosmetic only.

NON-BUG #2 (Symptom 3 — "live_context.md missing details"):
- File IS on disk with current-gen content. If content is thin, the LLM's signoff composer is the lever (skill doc), not Rust code.
- File writes precede git commits, so seal failures cannot truncate live_context.md.

ADJACENT FINDING (low-priority hardening):
- The 2000ms retry budget in `src/live/start.rs:592-630 emit_boot_trigger_after_spawn` is too tight for cold `claude -p` startup. Either raise `MAX_ATTEMPTS` to 60-80 (15-20s budget) OR refactor to a sentinel-file signal from wrapper → listener. Independent of the seal bug.

## Symptoms

expected:
  - doyle listener seals its sessions log cleanly each gen
  - $LOCALAPPDATA/spt/psyches/tracked/projects/claude_skill_owl/ exists with sessions.log + agents/
  - doyle's live_context.md contains content from last signoff

actual:
  - "WARNING: sessions log seal failed for doyle gen 4 : git failed: fatal: Unable to create ... Another git process seems to be running"
  - psyches/ exists but reportedly empty (no tracked/ subtree)
  - "WARNING: wrapper-state.json missing or empty session_uuid for doyle after 2000ms; skipping boot row"
  - live_context.md missing last signoff content

errors:
  - "git failed: fatal: Unable to create 'C:/Users/decid/AppData/...' / Another git process seems to be running in this repository"
  - "sessions log seal failed for doyle gen 4"
  - "wrapper-state.json missing or empty session_uuid for doyle after 2000ms; skipping boot row"

reproduction: doyle listener-poll on a session boundary attempting to seal previous gen

started: After Phase 24/24.1 (sessions-log + agents info.json) and/or Phase 25 (nested perches) work landed

## Eliminated

- hypothesis: psyches/ directory empty / Phase 24 init never ran
  evidence: `psyches/tracked/seed/` IS populated — `HEAD`, `config`, `objects/`, `worktrees/{deployah,doyle,dunsen,executor,higsby,mica,todlando,webber,witty}/` all present. Per-agent worktrees materialized at `psyches/tracked/agents/{id}/` with `sessions.log`, `info.json`, `live_context.md`, `memformat.xml`, `daemon.log`, `.git` worktree-marker file. Bootstrap, `ensure_seed`, and `ensure_agent_worktree` are all working. User's "psyches dir is empty" report was incorrect (likely a `dir` against the wrong path or stale recollection).
  timestamp: 2026-05-22T12:15:00Z

- hypothesis: live_context.md content lost due to seal/commit failure
  evidence: `route_two_slice_signoff` (src/live/signoff.rs:94-141) writes `live_context.md` FIRST (line 102: `std::fs::write(&ctx_path, body)`), THEN attempts the git commit (line 114). Commit failure logs a warning but the file content is already on disk. File content survives any git-layer error. Current `agents/doyle/live_context.md` says "Generation 4 spawn. Started 2026-05-22. Prior context absorbed." — content IS present and reflects gen-3-signoff payload.
  timestamp: 2026-05-22T12:20:00Z

- hypothesis: Phase 25 nested perches collided with Phase 24 sessions-log layout
  evidence: Phase 25 is `planning` status per STATE.md (29/29 prior plans complete, Phase 25 not executed). The nested perch (`owlery/doyle/nested/doyle-psyche/`) exists because some Phase 25 paths landed via earlier work, but the sessions.log code path is fully under Phase 24 and uses `psyches/tracked/agents/{id}/sessions.log` — orthogonal to the perch nesting layout.
  timestamp: 2026-05-22T12:22:00Z

- hypothesis: Phase 35 sessions.log sync code is colliding (commit 96e77d0 touched Phase 35 CONTEXT D-02 about sessions.log syncs)
  evidence: 96e77d0 only edits `.planning/phases/35-.../35-CONTEXT.md` docs. No `src/` changes. Phase 35 is `planning` per STATE.md.
  timestamp: 2026-05-22T12:25:00Z

## Evidence

- timestamp: 2026-05-22T12:10:00Z
  checked: `Grep` for "sessions log seal" in src/
  found: Single emit site at `src/live/start.rs:583-588` — `seal_and_rotate_sessions_log(self_id, prior_gen)` inside `emit_boot_trigger_after_spawn`. Implementation at `src/common/tracked.rs:1239-1269`.
  implication: Seal logic is correctly invoked. The failure is downstream in `commit_agent_payload` → `git -C agents/doyle add sessions.log` / `git ... commit`.

- timestamp: 2026-05-22T12:11:00Z
  checked: `Grep` for "missing or empty session_uuid"
  found: TWO emit sites:
    - `src/live/start.rs:622-626` ("after 2000ms; skipping boot row")
    - `src/live/signoff.rs:225-228` (no time qualifier; "skipped" on signoff)
  implication: Both read via `read_wrapper_state(psyche_id)` → `wrapper_state_path("doyle-psyche")` → `perch_dir("doyle-psyche").join("wrapper-state.json")` → `owlery/doyle-psyche/wrapper-state.json` (TOP-LEVEL flat path).

- timestamp: 2026-05-22T12:12:00Z
  checked: On-disk wrapper-state copies for doyle-psyche
  found:
    - `owlery/doyle-psyche/wrapper-state.json` = `{session_uuid: "9e11af13-...", gen: 4, last_fresh_launch_epoch: 1779450796}` (created today)
    - `owlery/doyle/nested/doyle-psyche/wrapper-state.json` = `{session_uuid: "1be5a33b-...", gen: 52, last_fresh_launch_epoch: 1779412550}`
  implication: TWO live writer paths exist for wrapper-state. The top-level perch is the path read by the Phase 24 sessions-log emitter. The nested copy belongs to a DIFFERENT psyche-wrapper instance (gen 52 — much older `last_fresh_launch`). One psyche-wrapper process writes to top-level (`perch_dir("doyle-psyche")`), another writes to nested (`nested_perch_dir("doyle", "doyle-psyche")`). They are not the same process.

- timestamp: 2026-05-22T12:13:00Z
  checked: STATE.md milestone status
  found: Phase 24 = complete, Phase 24.1 = complete, Phase 25 = planning. Phase 25 Plan 03 / `route_two_slice_signoff` IS in `src/live/signoff.rs` (referenced as "Phase 25 Plan 03 Task 4") — partial Phase 25 surface area landed before milestone flip.
  implication: Phase 25 "perch nesting" code (the `nested_perch_dir` writer side) is partially live — enough that nested wrapper-state.json files are being written for some psyche instances, but the listener-side `read_wrapper_state` reader is STILL using the flat `perch_dir(id)` path.

- timestamp: 2026-05-22T12:14:00Z
  checked: `psyches/tracked/seed/worktrees/doyle/`
  found: `index.lock` — **0 bytes, mtime 2026-05-21 23:40:10** (pre-dates gen-4 boot at 2026-05-22 04:53; ≈5 hours stale).
  implication: **SMOKING GUN.** Git writes `index.lock` as a coordination sentinel at the start of any index-mutating operation, then renames it to `index` on success. A 0-byte stale lock means a previous `git add` / `git commit` was interrupted (crash, SIGKILL, host reboot, owl.exe handoff mid-commit, etc.) and never cleaned up. Every subsequent `git -C agents/doyle add sessions.log` / `git ... commit` now fails with "Unable to create '.../index.lock': Another git process seems to be running." This is the EXACT error in the seal warning. No actual concurrent git process exists.

- timestamp: 2026-05-22T12:16:00Z
  checked: `src/common/tracked.rs:1247-1268` (seal_and_rotate_sessions_log)
  found:
    ```
    let wt = ensure_agent_worktree(agent_id)?;
    let path = wt.join("sessions.log");
    if !path.exists() { return Ok(()); }
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    if body.trim().is_empty() { return Ok(()); }
    let subject = format!("sessions: {} seal gen {}", agent_id, generation);
    commit_agent_payload(agent_id, &["sessions.log"], &subject)?;     // <-- fails here
    owlery::atomic_write_string(&path, "").map_err(TrackedError::Io)?; // <-- never reached
    ```
  implication: Failure point is `commit_agent_payload` (which runs `git add` then `git commit`, both blocked by stale index.lock). Because commit returns early with `Err`, the `atomic_write_string(&path, "")` truncation also never runs — so sessions.log keeps growing across gens without rotation.

- timestamp: 2026-05-22T12:17:00Z
  checked: `agents/doyle/sessions.log` content
  found: 9 lines — boot at 2026-05-22T03:26:54Z, then 8 commune/pulse rows spanning 03:26 → 09:11. No `signoff` rows. Most recent row is the 09:11 commune. All entries are from the SAME real wrapper session window (sessions.log is supposed to roll on every boot but hasn't because seal fails on each gen).
  implication: Sessions.log has been accumulating across multiple `boot`/`pulse`/`commune` triggers because seal-on-boot has failed every time. Tags `doyle-gen-{4,5,6,7,8}` in `tracked/.git/refs/tags/` are from the LEGACY pre-Phase-24 `tracked/.git` repo (also present at `psyches/tracked/.git/` per the comment at `tracked.rs:1317` "Pitfall 6 — Phase 23-era tracked/.git/ preserved in place") — they are NOT Phase 24 seal commits. The Phase 24 seal commits live in `psyches/tracked/seed/` and there are NONE for doyle past whatever last succeeded.

- timestamp: 2026-05-22T12:18:00Z
  checked: psyches/tracked/projects/
  found: Directory does NOT exist.
  implication: No `commune` or `signoff` payload to date has carried a `<project-context>` slice (which is what triggers `ensure_project_worktree` via `commit_project_payload`). `route_two_slice_signoff` (signoff.rs:143) and `echo_commune::route_two_slice` only create the project worktree when `slices.project.is_some()`. The two-slice envelope is a Psyche-LLM-driven content shape (Phase 25.1 skill docs teach it; Phase 25 main not executed). Either the Psyche has not yet emitted a tagged two-slice commune/signoff in cwd `claude_skill_owl`, or recent attempts ran the project commit AFTER the failing seal aborted with the index.lock (less likely — `route_two_slice_signoff` runs project_worktree creation independently of `commit_project_payload` success, and project worktree is in a DIFFERENT worktree dir so the doyle index.lock would not affect it). The first explanation is overwhelmingly more likely — this is a skill/content behavior, not a code defect.

- timestamp: 2026-05-22T12:19:00Z
  checked: `wrapper_state.rs:117` path resolver
  found: `wrapper_state_path(agent_id) = perch_dir(agent_id).join("wrapper-state.json") = owlery/{agent_id}/wrapper-state.json`. Comment at lines 101-104 explicitly notes `agent_id` here is the **psyche id** (`{self_id}-psyche`), composed by callers.
  implication: When `emit_boot_trigger_after_spawn("doyle", "doyle-psyche")` runs `read_wrapper_state("doyle-psyche")`, it reads `owlery/doyle-psyche/wrapper-state.json` (TOP-LEVEL flat path) — not the nested one. The top-level file IS present and has a non-empty `session_uuid`. So the 2000ms warning SHOULD NOT have fired — unless the file did not yet exist OR was being mid-written at the moment of the read attempts (race against the wrapper's `init_session` post-write at `wrapper/claude.rs:172`). The 8 retries × 250ms = 2000ms window covers the typical `claude -p` UUID-capture latency, but cold-disk / Defender scan / high CPU contention on the bigscreen host can blow past 2s.

- timestamp: 2026-05-22T12:21:00Z
  checked: `src/live/wrapper/claude.rs:155-178` (wrapper post-init_session sequence)
  found: After `git_commit_context(&owlery::psyche_dir(), &self.self_id)` runs (line 155), the wrapper builds `WrapperHandoffState { session_uuid, gen, last_fresh_launch_epoch }` and calls `wrapper_state_helper::write_wrapper_state(&self.psyche_id, &state)` (line 172). `psyche_id` here is `format!("{}-psyche", self_id)` → `"doyle-psyche"`. So the wrapper publishes to `owlery/doyle-psyche/wrapper-state.json` — the same flat top-level path the start.rs emitter reads.
  implication: Reader and writer agree on the path. The retry race is the only plausible cause of the "missing or empty session_uuid for doyle after 2000ms" warning. Stale top-level wrapper-state.json from a prior incarnation may have been read OK (session_uuid non-empty) — but if the boot emit fired BEFORE the new wrapper's first `init_session` completed `write_wrapper_state`, the file content would still be from a prior boot. Today's gen 4 top-level wrapper-state shows `last_fresh_launch_epoch: 1779450796` (= today, ~04:53 PDT) which lines up with `info.json.started = "2026-05-22 04:53:17 PDT"` — i.e. the writer DID land. So the 2s-warning must have fired on an EARLIER gen (gen ≤3) before the wrapper's first init completed. The warning in the user's stderr capture is presumably from an earlier session boundary; the current state shows the file populated. Or — alternative — the warning was emitted by a stop/revive path that re-emit-boot'd before the wrapper rehydrated.

## Resolution

root_cause: **Single root cause behind Symptom 1; Symptoms 2 and 3 are user misreads / orthogonal skill-content issues.**

### Symptom 1 (sessions.log seal failed gen 4 — "Another git process seems to be running")

**ROOT CAUSE:** Stale 0-byte `index.lock` file at:
```
$LOCALAPPDATA\spt\psyches\tracked\seed\worktrees\doyle\index.lock
```
Mtime 2026-05-21 23:40:10 PDT — predates today's gen-4 boot by ~5 hours. Created when a prior `git add`/`commit` against `agents/doyle/` was interrupted before completing rename `index.lock → index`. Standard git crash-recovery artifact; not a logic bug.

Once present, every subsequent `git add` / `git commit` invocation routed through `commit_agent_payload("doyle", &["sessions.log"], ...)` at `src/common/tracked.rs:1262` fails immediately in `git`'s lock acquisition (before any actual index touch). Git emits the verbatim "Another git process seems to be running" message even though no concurrent git process exists.

Because `seal_and_rotate_sessions_log` returns `Err` on the commit, the subsequent `atomic_write_string(&path, "")` truncation (line 1267) never runs. Sessions.log keeps accumulating across gens — currently 9 unsealed rows from a single contiguous wrapper-session window.

The "boot row" append fails the same way at `tracked.rs::append_session_entry` (path = `agents/doyle/sessions.log`), so each generation's boot trigger ALSO cannot land a fresh row. Sessions.log grows monotonically; no seal commits land; no rotation.

**File:line:**
- Failure site: `src/common/tracked.rs:1262` — `commit_agent_payload(agent_id, &["sessions.log"], &subject)?`
- Surfaced as warning: `src/live/start.rs:583-588` — `eprintln!("WARNING: sessions log seal failed for {} gen {}: {} (continuing)", ...)`
- Lock location: `psyches/tracked/seed/worktrees/{agent}/index.lock` (per-worktree, NOT seed-wide)

**Classification:** Init code ran correctly. Phase 24 logic ran correctly. This is a **runtime crash-recovery gap**: no orphan-index.lock detection/removal in `ensure_agent_worktree` / `ensure_seed` (only `worktree prune` runs, which does NOT clean index.lock files).

### Symptom 2 ("psyches/tracked/projects/ subtree missing")

**Not a bug — expected for current Psyche LLM behavior.** `psyches/tracked/seed/` and `psyches/tracked/agents/{doyle,deployah,dunsen,executor,higsby,mica,todlando,webber,witty}/` ARE all present and populated (user's report was wrong). `projects/{claude_skill_owl}/` is created lazily ONLY when a commune/signoff payload carries a `<project-context>` slice. To date no Psyche emit has tagged a two-slice envelope. This is governed by Phase 25.1 skill docs (recently shipped — `/spt:commune` + `/spt:signoff` teach the two-slice shape). Once a tagged commune or signoff fires from inside this cwd, `ensure_project_worktree("claude_skill_owl")` will materialize `projects/claude_skill_owl/` automatically.

User's confusion likely stems from re-reading older docs / older debug session expecting eager init.

### Symptom 3 ("live_context.md missing details from last signoff")

**Not a bug — content/behavior issue, NOT a code defect.** `route_two_slice_signoff` (`src/live/signoff.rs:94-141`) writes `live_context.md` BEFORE attempting the git commit (line 102 `fs::write` precedes line 114 `commit_agent_payload`). File content on disk survives any git failure. Current `agents/doyle/live_context.md` carries gen-3-signoff content ("Generation 4 spawn. Started 2026-05-22. Prior context absorbed.") — content IS present.

If the user feels content is thin, that's a Psyche-LLM signoff-composition issue (the LLM didn't include enough detail in its `<live-context>` slice), not a code-path failure.

Additionally: the seal failure does NOT block file writes — only commits. So even with the index.lock present, `live_context.md` writes go through. The "missing details" symptom is unrelated to Symptom 1.

### Bonus finding (was in user's "Confirmed facts so far" but worth surfacing)

The TWO copies of `doyle-psyche` perches (`owlery/doyle-psyche/` top-level + `owlery/doyle/nested/doyle-psyche/`) are NOT from a single race — they belong to DIFFERENT psyche-wrapper processes:
- Top-level wrapper-state: `gen 4`, `last_fresh_launch_epoch: 1779450796` (≈04:53 today) — recent.
- Nested wrapper-state: `gen 52`, `last_fresh_launch_epoch: 1779412550` (≈18:15 yesterday) — older long-running wrapper still alive, writing to nested layout.

Phase 25 perch-nesting code IS partially landed (`nested_perch_dir` helper exists at `src/common/owlery.rs:161`; `route_two_slice_signoff` references "Phase 25 Plan 03 Task 4"). Some psyche wrappers have started writing to `nested_perch_dir(parent, child_id)` while older wrappers still write to flat `perch_dir(child_id)`. This is migration overlap, not a collision today — but it's a **latent risk for Phase 25 execution**: if Phase 25 Plan flips `read_wrapper_state` to read nested-first while the writer fleet is mixed, the sessions-log session_uuid reads will silently use stale or empty paths for any wrapper not yet handed-off to the new code.

User's claim "top-level doyle-psyche/info.json is EMPTY (0 bytes)" was wrong — the file simply does not exist (and never did; only top-level wrapper-state.json + spool.db are present, alongside the nested perch's full `info.json`).

fix: (diagnose only — see "Proposed fix" below; NO edits applied)

verification: (TBD; not applicable — diagnose only)

files_changed: []

---

## Proposed Fix (what to change where — DO NOT apply yet)

### Primary fix (closes Symptom 1)

Add stale-index.lock detection + removal to `ensure_worktree` fast-path in `src/common/tracked.rs:335-432`.

**Suggested location:** Inside `ensure_worktree`, after the fast-path `.git`-marker check at line 363-366. Before returning `Ok(wt)`, probe for a stale `index.lock` at `seed/worktrees/{name}/index.lock` (note: NOT inside the worktree dir itself — git stores per-worktree admin files under the seed's `worktrees/{name}/` subdir, alongside `gitdir`, `HEAD`, `commondir`).

**Suggested algorithm (Pitfall 1-style best-effort cleanup):**
```
if wt.join(".git").exists() {
    // Probe for stale index.lock in the seed's per-worktree admin dir.
    let seed = ensure_seed()?;
    let admin = seed.join("worktrees").join(name);
    let lockfile = admin.join("index.lock");
    if let Ok(meta) = std::fs::metadata(&lockfile) {
        // 0-byte AND mtime older than N seconds (e.g. 60) = orphan.
        // git creates index.lock then writes immediately; a >60s
        // 0-byte file means the writer crashed.
        let stale = meta.len() == 0 && meta.modified()
            .ok()
            .and_then(|m| m.elapsed().ok())
            .map(|d| d.as_secs() > 60)
            .unwrap_or(false);
        if stale {
            let _ = std::fs::remove_file(&lockfile);
            // (optionally log a single stderr line so operators notice)
        }
    }
    return Ok(wt);
}
```

**Why per-worktree admin path, not worktree dir:** Git's index.lock for a linked worktree lives in the seed's `worktrees/{name}/` admin subdir, NOT in the working-copy directory. Verified above: `psyches/tracked/seed/worktrees/doyle/index.lock` is the actual path of the offending file.

**Open question for fix author:** Should the staleness threshold also gate on "no running git process holds the file open"? On Windows, a held lock fails the `remove_file` call cleanly, so a try-and-fall-through pattern would be safe even without an explicit liveness probe. Recommend the simpler form.

**Alternative / additive:** add this same probe to the cold-path `ensure_seed` fast-path (`src/common/tracked.rs:184-195`, right after the existing `worktree prune` call) so EVERY agent's admin dir is checked on every ensure_seed invocation, not just the one being looked up. Either placement works; per-worktree probe is more surgical (no cross-agent IO).

### Manual recovery (unblock NOW, before fix lands)

```powershell
Remove-Item "$env:LOCALAPPDATA\spt\psyches\tracked\seed\worktrees\doyle\index.lock"
```

After this single-file deletion, the next sessions.log seal attempt (next gen boundary) WILL succeed. No restart needed.

Apply the same fix to any other agent whose seal warnings show the same lock error — repeat for `deployah`, `dunsen`, `executor`, `higsby`, `mica`, `todlando`, `webber`, `witty` if any of them are showing seal failures. (`Glob` for `index.lock` under all worktree admin dirs is the cleanest sweep.)

### Symptom 2 (no fix needed)

`projects/claude_skill_owl/` will materialize naturally on the next two-slice commune/signoff with a `<project-context>` slice. If the user wants to force-init it, manually run something equivalent to `crate::common::tracked::ensure_project_worktree("claude_skill_owl")` via a debug subcommand — but there's no reason to: the lazy materialization is by design (Phase 24 D-16).

### Symptom 3 (skill-content / Psyche LLM behavior)

If the user wants signoff content to be richer, that's a `/spt:signoff` skill doc revision — not a Rust code change. Out of scope for this debug.

### Optional hardening (latent risk noted above)

When Phase 25 actually executes and flips `read_wrapper_state` to the nested path, add a fallback chain: try `nested_perch_dir(parent, child)/wrapper-state.json` FIRST, fall back to `perch_dir(child)/wrapper-state.json` SECOND. This handles the migration overlap window where some wrappers still write to flat paths. Phase 25 plan owner should hold this — flag it now so it doesn't surprise them.
