# Phase 24: Tracked-Dir Forked-Repo Layout — Research

**Researched:** 2026-05-20
**Domain:** Git plumbing (bare repo + linked worktrees) + filesystem state migration + Rust subprocess soft-fail
**Confidence:** HIGH

## Summary

Phase 24 restructures `$SPT_HOME/psyches/tracked/` into a **bare git repository (`seed/`) plus N linked worktrees** (`agents/{id}/`, `projects/{name}/`) with branch namespace `a-{id}` / `p-{name}`. Every commune/signoff/echo/pulse becomes a git commit in the appropriate worktree (Phase 23 Stamp embedded as commit trailers via a new `Stamp::commit_trailers()` renderer), and per-agent activity is tracked in a single-file JSONL log `agents/{id}/sessions.log` that's truncate-on-rolled at every new generation (prior gens recoverable via `git log -p sessions.log`).

The git CLI ships everything Phase 24 needs, but **three sharp edges were confirmed by live testing on Windows git 2.43**: (1) `git commit --allow-empty` does NOT work in a bare repo — bootstrap requires `commit-tree` + `update-ref` + `symbolic-ref` instead [VERIFIED: live test on git 2.43.0.windows.1, 2026-05-20]; (2) worktrees created from a bare repo have NO `origin` remote configured by default — push semantics require `git remote add origin ../../seed` per worktree, or push by relative path [VERIFIED: same test]; (3) `rm -rf` of a worktree directory leaves orphan metadata under `seed/worktrees/{name}/` that blocks future `git worktree add` with the same branch until `git worktree prune` is run [VERIFIED: same test]. All three drive specific planner decisions documented below.

**Primary recommendation:** Build a new module `src/common/tracked.rs` (sibling to `git.rs`) that wraps every git invocation in the Phase 23 500ms-soft-timeout pattern from `src/common/git.rs::run_git_with_timeout`. Expose `tracked_root()`, `seed_path()`, `agent_worktree(id)`, `project_worktree(name)` helpers in `owlery.rs`; expose `ensure_seed()`, `ensure_agent_worktree(id)`, `ensure_project_worktree(name)`, `commit_and_push(worktree, subject, trailers)`, `migrate_legacy_if_needed()`, `doctor_status_table()` in `tracked.rs`. Add `Stamp::commit_trailers(scope: TrailerScope) -> String` to `git.rs`. Fold all write-path call sites in `src/live/context.rs` and `src/live/signoff.rs` to route through `tracked.rs`. Migration triggers from one location only — `tracked_root_or_migrate()` called lazily on first write — not from `start.rs`, to cover the plain `$OWL listen` path as well.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Bare seed init + bootstrap commit | `src/common/tracked.rs` (new) | `src/common/owlery.rs` (path helpers) | Pure plumbing; owlery already owns `psyches/tracked/` path resolution |
| Worktree creation (lazy, per-agent/project) | `src/common/tracked.rs` | — | Same module owns the entire tracked-repo lifecycle |
| Stamp rendering as commit trailers | `src/common/git.rs::Stamp::commit_trailers` | — | Sibling to existing `event_attrs()` + `yaml_frontmatter()`; same struct, same helpers |
| Write-then-commit-then-push pipeline | `src/live/context.rs` (refactor `git_commit_context`) + `src/live/signoff.rs` (write `live_context.md`-style payload) | `src/common/tracked.rs::commit_and_push` | Call sites stay in live/ to keep payload composition near payload write |
| Sessions log JSONL append | `src/common/tracked.rs::append_session_entry` | Callers: wrapper boot/pulse/commune/signoff trigger points | One owner for the file format; many call sites |
| First-boot migration | `src/common/tracked.rs::migrate_legacy_if_needed` invoked from `ensure_seed()` | — | Lazy + idempotent — no boot-path-specific trigger needed (covers `$LIVE start` AND `$OWL listen`) |
| Doctor per-worktree status table | `src/owl/doctor.rs::check_tracked_layout` (new) | `src/common/tracked.rs::doctor_status_rows` | Doctor owns rendering; tracked.rs owns the data computation |
| Soft-fail subprocess wrapping | `src/common/git.rs::run_git_with_timeout` (existing, reuse) | — | Already the canonical soft-timeout primitive for git subprocesses |

## Project Constraints (from CLAUDE.md)

- **Platform:** Windows native + Unix. `git` CLI MUST exist on PATH at runtime, but D-02 mandates graceful degradation when absent.
- **Portability:** Zero runtime deps beyond the binary. `git2-rs` is forbidden (D-01).
- **Backward compat:** Existing skill commands (`commune`, `signoff`, `live`, `revive`, `list-agents`) must keep working unchanged. SKILL.md files do not change.
- **State root:** `$SPT_HOME` or platform default; never hard-code `%LOCALAPPDATA%`. Use `crate::common::owlery::psyche_dir()` (returns `$SPT_HOME/psyches/tracked/`).
- **`psyche.md` embedded via `include_str!`:** No relation to Phase 24; leave alone.
- **GSD-routed edits only:** No direct repo edits outside GSD. Plans must check this in pre-flight.
- **Output convention:** Status to stderr (ANSI-colored), payload body to stdout. `git: {op} failed: ...` warnings go to stderr per Phase 23 D-13.
- **Worktrees disabled for THIS repo (CLAUDE.md memory):** Refers to git-worktree on this project's *own* `claude_skill_owl` checkout (Phase 15 merge recovery bug); does NOT apply to the SPT runtime where Phase 24 itself uses worktrees. No conflict.

## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** All git ops shell out to the system `git` CLI; no `git2-rs`, no bundled git. Phase 23's 500ms soft-timeout + rate-limited stderr warning applies to ALL Phase 24 git invocations.
- **D-02:** Missing-git fallback = degrade silently. Commune/signoff writes still land in `agents/{id}/` + `projects/{name}/` directories (raw files, not versioned). One-shot stderr warning. `$LIVE doctor` reports `git missing — tracked-dir not versioned`.
- **D-03:** `psyches/tracked/seed/` is a bare git repo. Each `agents/{id}/` + `projects/{name}/` is a `git worktree add` linked to seed. Disk cost ~1× (de-duped objects). Phase 35 remote swap is one-line.
- **D-04:** Branch namespace `a-{agent_id}` / `p-{project_name}`. Folders stay prefix-less: `agents/doyle/`, `projects/claude_skill_owl/`. Mechanical mapping.
- **D-05:** `gc.worktreePruneExpire never` set on seed at init. Cleanup uses `git worktree remove`, never `rm -rf`. Doctor surfaces orphans via `git worktree prune --dry-run`.
- **D-06:** Bootstrap seed with an empty initial commit. Subject: `init: tracked seed`.
- **D-07:** Commit subjects: `{kind}: {self_id} {payload-type} — {short}`, where `{kind}` ∈ `{commune, signoff, echo, context-save, amend-signoff, sessions, migrate}` and `{short}` is a 50-char truncated context excerpt.
- **D-08:** Commit trailers carry Phase 23 Stamp. Agent worktree commits: `Machine`, `Project`, `Branch`, `Head-SHA`, `Head-Subject`. Project worktree commits: `Machine`, `Branch`, `Head-SHA`, `Head-Subject` (omits `Project`, implied by directory). Optional fields (branch/head_sha/head_subject) follow Phase 23 D-11: omitted entirely when not in a repo.
- **D-09..D-12:** Sessions log = `agents/{id}/sessions.log`, JSONL, 3 fields locked-order (`ts`, `session_uuid`, `trigger`). Trigger enum: `boot`, `pulse`, `commune`, `signoff`. Seal-on-roll = git-commit the file with `sessions: {self_id} seal gen {N}` then truncate to empty before appending first new-gen entry.
- **D-13:** Write path = (write payload) → `git -C {worktree} add` → `git -C {worktree} commit` → `git -C {worktree} push origin {branch}`. Soft-fail per D-02 / Phase 23 D-13.
- **D-14:** Cross-machine conflicts = Phase 35's problem. `seed/` is local-only until then.
- **D-15:** Auto-migrate on first boot. Detection: `psyches/tracked/seed/` absent AND any of `tracked/{id}.log` / `tracked/{id}.md` / `tracked/{id}-memformat.xml` present. Single commit per migrated agent: `migrate: {id} — import legacy flat layout`. Stderr summary: `migrated N agents to forked layout`. Legacy files removed on success.
- **D-16:** Worktrees created lazily on first relevant write (commune/signoff/echo/pulse for agent; first project-scoped write for project).
- **D-17:** `$LIVE doctor` reports per-worktree status: `{folder} → {branch} → {clean|dirty|N unpushed}`. Sources: `git worktree list --porcelain` + `git status --porcelain` + `git rev-list --count`.

### Claude's Discretion

- Exact subject-line truncation algorithm (D-07 50-char `{short}`) — recommend reuse of Phase 23 `cap_subject_72` pattern scaled down. **Researcher recommendation: see Pattern 4 below.**
- Migration detection check fire location within `src/live/start.rs` / `src/common/owlery.rs`. **Researcher recommendation: lazy from `tracked.rs::ensure_seed()`, not boot-specific. See Pitfall 4.**
- `Stamp::commit_trailers()` return type: `String` (joined) vs `Vec<(&str, String)>` (structured). **Researcher recommendation: `String` joined with `\n`. See Pattern 1.**
- Empty initial commit author identity. **Researcher recommendation: `spt-bootstrap <spt@local>` via `-c user.name=` / `-c user.email=` argv (zero global git config impact). See Pattern 2.**
- Test coverage breakdown (unit / golden / integration) — recommendations in Validation Architecture below.
- Exact `$LIVE doctor` table rendering — recommendation in Pattern 5.

### Deferred Ideas (OUT OF SCOPE)

- Per-machine UUID for `machine` field (Phase 35 territory).
- `seed/` history squash / shallow clone.
- `$LIVE migrate` opt-in subcommand (auto-migrate is default; opt-in can come later).
- Sessions log retention / pruning policy (git history unbounded for now).
- Cross-machine concurrent push conflict policy (Phase 35).
- Project worktree pre-creation during migration (stays lazy).

## Phase Requirements

Phase 24 requirement IDs come from ROADMAP §Phase 24 (line 415). They are listed as "TBD" — the planner should LOCK these IDs into REQUIREMENTS.md as part of Phase 24 planning.

| ID | Description (from ROADMAP) | Research Support |
|----|----------------------------|------------------|
| TRK-FORK-01 | `psyches/tracked/seed/` is initialized as a git repo on first run; SPT helpers manage it (SC 1) | D-06 bootstrap; Pattern 2 (`commit-tree`+`update-ref`+`symbolic-ref`) |
| TRK-AGENT-BRANCH-01 | `psyches/tracked/agents/{agent_id}/` is a branched worktree of `seed/`; branch = `a-{id}`; lazy on first commune/signoff (SC 2) | D-03, D-04, D-16; Pattern 3 (`git worktree add ../agents/{id} -b a-{id} main`) |
| TRK-PROJECT-BRANCH-01 | `psyches/tracked/projects/{project_name}/` is a branched worktree; branch = `p-{name}`; lazy on first project-scoped write (SC 3) | D-03, D-04, D-16 |
| TRK-RENAMES-01 | File renames applied per SC 4 (`{id}.log` → `agents/{id}/daemon.log`; `{id}.md` → `agents/{id}/live_context.md`; `{id}-memformat.xml` → `agents/{id}/memformat.xml`) | D-15 migration; SC 4-5; touchpoints in `src/live/context.rs` (`{id}.md` writer), `src/live/start.rs::relocate_previous_log` (`{id}.log` writer), `src/live/fork.rs` (`{id}-memformat.xml` writer) |
| SESSIONS-LOG-01 | Wrapper appends to `agents/{id}/sessions.log` on every fresh `claude -p` (no `--resume`) (SC 7) | D-09, D-10, D-11; Pattern 6 (JSONL append-only) |
| SESSIONS-LOG-02 | `/spt:revive` and any new-generation event seals prior log + starts a new one (SC 8) | D-12 truncate-on-roll; Pattern 6 |
| GEN-ROLLOVER-01 | (Same as SESSIONS-LOG-02 in spirit — generation rollover semantics) | D-12; generation count from `info.json::generation` (current `read_status` path); commit subject `sessions: {id} seal gen {N}` |
| TRK-MIGRATE-01 | Existing flat-layout files migrate to new layout on first boot of new binary, OR coexist as legacy without breaking listings (SC 9) | D-15 auto-migrate path; Pitfall 4 idempotence |

**Additional SCs the planner must cover:**
- SC 6 — every commune/signoff commits + pushes to seed (D-13)
- SC 10 — `$LIVE doctor` recognizes the layout (D-17; Pattern 5)
- SC 11 — helpers updated end-to-end across `owlery.rs`, `start.rs`, `doctor.rs`, commune/signoff write paths, and `$LIVE psyche-download` (Phase 25 forward-compat surface)

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| **system `git` CLI** | ≥ 2.5 (worktree); shipping Windows git 2.43+ verified | All git plumbing | D-01 locked. `git2-rs` would add ~6MB libgit2 binding and break zero-dep constraint |
| `std::process::Command` (Rust std) | (std) | Subprocess invocation | Already used everywhere in this crate; same `crate::common::process::hide_window` pattern for Windows CREATE_NO_WINDOW |
| `tempfile` (already in `Cargo.toml`) | existing | Test fixtures (tempdir-based git repos) | Phase 23 already uses; consistent with rest of test suite |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `serde_json` (already in `Cargo.toml`) | existing | JSONL line serialization for sessions log | D-10 lock-order field set; need `preserve_order` feature (already enabled per `derive_current_repo_names` precedent) |
| `chrono` (already in `Cargo.toml`) | existing | `ts` field as ISO-8601 UTC | Matches `format_timestamp()` in `src/common/time` |
| Phase 23 `Stamp` (`src/common/git.rs`) | existing | Trailer field source | Add `commit_trailers()` renderer beside existing `event_attrs()` + `yaml_frontmatter()` |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Bare repo + worktrees | N separate git repos (one per agent) | Would duplicate the object DB N times; loses atomic push semantics; Phase 35 GitHub-remote swap becomes O(N) instead of O(1). D-03 locks against this. |
| Lazy worktree creation | Eager creation at first boot | Eager wastes disk for never-active agents/projects; lazy keeps state proportional to actual use. D-16 locks lazy. |
| JSONL with serde struct | Hand-rolled `format!` | `serde_json::to_string` over a tuple struct with `#[serde(rename_all = "lowercase")]` plus `Cargo.toml`'s `preserve_order` feature gives field-order guarantee. **Recommended: serde struct.** Hand-rolled is fragile. |
| `String` return from `commit_trailers()` | `Vec<(&'static str, String)>` | Caller ergonomics: every existing trailer-emit site wants to splat the trailers into a `-m "subject\n\n{trailers}"` argv. The struct variant forces every caller to join — duplicated formatting logic. **Recommended: `String`.** |
| Migration detection in `start.rs` | Lazy detection in `tracked.rs::ensure_seed()` | `start.rs` is the live-agent boot path. `$OWL listen` (plain owl listener) does NOT route through `start.rs` but DOES need to migrate legacy flat files. **Recommended: lazy** — covers both paths in one location. |

**Installation:** No new dependencies. All Phase 24 code uses existing crate deps + system git.

**Version verification:**
- `git 2.5+` introduced `git worktree`. Windows ships 2.43.0 by default (Git for Windows installer); macOS 14+ ships 2.39+; Linux distros all > 2.30. **No fallback needed** [VERIFIED: live test on git 2.43.0.windows.1, 2026-05-20].
- `gc.worktreePruneExpire` config key supported since git 2.6. [CITED: https://git-scm.com/docs/git-config — `gc.worktreePruneExpire`].

## Package Legitimacy Audit

Not applicable — Phase 24 installs zero new packages.

| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---------|----------|-----|-----------|-------------|-----------|-------------|
| (none) | — | — | — | — | — | — |

## Architecture Patterns

### System Architecture Diagram

```
                    ┌─────────────────────────────────────────┐
                    │  Triggers (wrapper, commune, signoff)    │
                    │  emit (kind, self_id, payload, scope)    │
                    └─────────────┬───────────────────────────┘
                                  │
              ┌───────────────────┼──────────────────────────┐
              │                   │                          │
              ▼                   ▼                          ▼
       sessions.log         payload file              git commit + push
       JSONL append      (live_context.md, etc.)    (D-13 write pipeline)
              │                   │                          │
              └───────────────────┴──────────────────────────┘
                                  │
                                  ▼
                    ┌──────────────────────────────┐
                    │  src/common/tracked.rs       │
                    │                              │
                    │  ensure_seed()               │
                    │  ensure_agent_worktree(id)   │◄── First call:
                    │  ensure_project_worktree(n)  │    bootstrap+migrate
                    │  commit_and_push(...)        │
                    │  append_session_entry(...)   │
                    └─────────────┬────────────────┘
                                  │
                                  ▼
                    ┌──────────────────────────────┐
                    │  src/common/git.rs           │
                    │  (Phase 23 — extended)       │
                    │                              │
                    │  Stamp::commit_trailers()    │
                    │  run_git_with_timeout()      │  ◄── 500ms soft-fail
                    └─────────────┬────────────────┘
                                  │
                                  ▼
                    ┌──────────────────────────────┐
                    │  $SPT_HOME/psyches/tracked/  │
                    │                              │
                    │  seed/        (bare repo)    │
                    │  agents/{id}/ (worktree)     │  ◄── lazy creation
                    │  projects/{n}/ (worktree)    │
                    └──────────────────────────────┘
```

### Recommended Project Structure (new and modified files)

```
src/
├── common/
│   ├── git.rs          # EXTEND — add Stamp::commit_trailers() + TrailerScope enum
│   ├── owlery.rs       # EXTEND — add tracked_root(), seed_path(), agent_worktree(id), project_worktree(name)
│   └── tracked.rs      # NEW — bare-repo + worktree management; migration; sessions log
├── live/
│   ├── context.rs      # MODIFY — git_commit_context refactored to route through tracked::commit_and_push
│   ├── signoff.rs      # MODIFY — write final payload into agent worktree
│   ├── start.rs        # MODIFY — sessions log `boot` trigger fires via tracked::append_session_entry
│   └── wrapper/
│       ├── claude.rs   # MODIFY — sessions log `pulse` trigger (every fresh `claude -p` minus --resume)
│       └── echo_fire.rs # MODIFY — sessions log `commune` trigger (echo_commune fires) — if applicable; see Pitfall 5
└── owl/
    ├── doctor.rs       # EXTEND — add check_tracked_layout() reporter
    └── echo_commune.rs # MODIFY — write echo_commune payload into agent worktree via tracked::commit_and_push
```

### Pattern 1: `Stamp::commit_trailers(scope: TrailerScope) -> String`

**What:** New renderer on `Stamp` returning the trailer block as `\n`-joined `Key: value` lines. Caller splats into `-m "subject\n\n{trailers}"`.

**When to use:** Every git commit Phase 24 produces (agent worktree OR project worktree commit).

**Recommended signature:**

```rust
// Source: extends src/common/git.rs (Phase 23 pattern)
pub enum TrailerScope {
    Agent,    // all 5 trailers: Machine, Project, Branch, Head-SHA, Head-Subject
    Project,  // 4 trailers: Machine, Branch, Head-SHA, Head-Subject (Project omitted per D-08)
}

impl Stamp {
    /// Render trailers as a `\n`-joined block. Optional fields (branch/head_sha/
    /// head_subject) follow D-11/D-08: OMITTED entirely when None — not set
    /// to empty. Returns "" if even machine/project are absent (impossible
    /// given Phase 23 `stamp()` guarantees them, but defensive).
    ///
    /// Git trailer convention (`git interpret-trailers`): Capitalized-Hyphenated
    /// keys, `Key: value` per line, blank line separating subject from trailer
    /// block in the full commit message.
    pub fn commit_trailers(&self, scope: TrailerScope) -> String {
        let mut lines: Vec<String> = Vec::new();
        lines.push(format!("Machine: {}", self.machine));
        if matches!(scope, TrailerScope::Agent) {
            lines.push(format!("Project: {}", self.project));
        }
        if let Some(b) = &self.branch       { lines.push(format!("Branch: {}", b)); }
        if let Some(s) = &self.head_sha     { lines.push(format!("Head-SHA: {}", s)); }
        if let Some(s) = &self.head_subject { lines.push(format!("Head-Subject: {}", s)); }
        lines.join("\n")
    }
}
```

**Caller pattern:**

```rust
let stamp = crate::common::git::stamp();
let trailers = stamp.commit_trailers(TrailerScope::Agent);
let full_message = format!("{}\n\n{}", subject, trailers);  // subject = D-07 shape
// Then: git -C {worktree} commit -m {full_message}
```

**Why `String` over `Vec`:** Every call site shape needs the joined form for `-m`. Returning `String` deduplicates the join.

[VERIFIED: pattern compatible with existing `Stamp::event_attrs() -> String` and `Stamp::yaml_frontmatter() -> String` in `src/common/git.rs:147,185`]

### Pattern 2: Bare-Repo Bootstrap (D-06)

**Critical finding:** `git commit --allow-empty -m "..."` does NOT work inside a bare repo. `fatal: this operation must be run in a work tree` [VERIFIED: live test, 2026-05-20].

**Correct sequence:**

```rust
// Source: VERIFIED via /tmp/wtest live test on git 2.43.0.windows.1
// 1. Init the bare seed
//    git init --bare {seed}
// 2. Config gc.worktreePruneExpire (D-05)
//    git -C {seed} config gc.worktreePruneExpire never
// 3. Build the empty tree
//    git -C {seed} hash-object -t tree --stdin </dev/null   →   4b825dc6...
// 4. Build the bootstrap commit (-c overrides for synthetic author per D-discretion)
//    git -C {seed} -c user.name=spt-bootstrap -c user.email=spt@local \
//        commit-tree {empty_tree} -m "init: tracked seed"
//    →   stdout = commit SHA
// 5. Point refs/heads/main at the commit
//    git -C {seed} update-ref refs/heads/main {commit_sha}
// 6. Point HEAD at refs/heads/main
//    git -C {seed} symbolic-ref HEAD refs/heads/main
```

**Why `commit-tree` instead of `commit`:** `commit-tree` operates on the object DB directly and does NOT require a working tree. The bare repo has no work tree, so this is the canonical path.

**Synthetic author identity (`-c user.name=` / `-c user.email=`):** Passes credentials via `-c` argv, NOT global git config. Zero side-effect on the operator's git config. **Recommendation: `spt-bootstrap <spt@local>`** (matches Phase 23 D-discretion guidance).

**Idempotence:** `ensure_seed()` checks `seed/HEAD` existence + `refs/heads/main` presence before running step 1-6. If both exist, skip.

### Pattern 3: Lazy Worktree Creation (D-16)

**Recommended sequence (per worktree):**

```rust
// 1. Verify seed is bootstrapped
//    ensure_seed()
// 2. Check whether the worktree exists (FS check)
//    if {worktree_path}/.git exists → return
// 3. Worktree add. Two cases:
//    (a) Branch doesn't exist yet (first time):
//        git -C {seed} worktree add {worktree_path} -b {branch} main
//    (b) Branch exists (e.g., recreated after manual rm -rf — see Pitfall 1):
//        git -C {seed} worktree add {worktree_path} {branch}
//    The CLI returns nonzero on (a) when branch already exists, and nonzero
//    on (b) when branch doesn't exist. Use git show-ref --verify --quiet
//    refs/heads/{branch} to disambiguate, OR try (a) first and fall through
//    to (b) on failure with stderr containing "already exists".
// 4. (Optional, future-compat) Add origin pointing back at seed for explicit push:
//    git -C {worktree_path} remote add origin {seed_absolute_path}
//    Note: without this step, `git push origin {branch}` fails with
//    "'origin' does not appear to be a git repository". [VERIFIED 2026-05-20]
//    See "Push semantics" decision in Pitfall 2.
```

[VERIFIED: live test 2026-05-20 — `git -C seed3 worktree add ../w3 -b a-doyle main` succeeds when seed has bootstrap commit; commit in worktree appears immediately in `git -C seed log --all` because objects are shared.]

### Pattern 4: D-07 Subject Composer (Pure Function)

**What:** Pure function `compose_commit_subject(kind, self_id, payload_type, body) -> String` producing the D-07 shape `{kind}: {self_id} {payload-type} — {short}`.

**Recommended truncation:** Reuse Phase 23 `cap_subject_72` pattern scaled to 50 chars:

```rust
// Source: adapt from src/common/git.rs:210 cap_subject_72
fn cap_short_50(raw: &str) -> String {
    let line = raw.lines().next().unwrap_or("").trim();
    let chars: Vec<char> = line.chars().collect();
    if chars.len() <= 50 {
        line.to_string()
    } else {
        let head: String = chars.iter().take(50).collect();
        format!("{}\u{2026}", head)   // U+2026 ellipsis
    }
}

fn compose_commit_subject(
    kind: &str,           // "commune" | "signoff" | "echo" | ...
    self_id: &str,
    payload_type: &str,   // "echo" | "init_signoff" | "context-save" | ...
    body: &str,           // free-form excerpt; truncated to 50 chars
) -> String {
    let short = cap_short_50(body);
    if short.is_empty() {
        format!("{}: {} {}", kind, self_id, payload_type)
    } else {
        format!("{}: {} {} — {}", kind, self_id, payload_type, short)
    }
}
```

**Why char-count (not byte-count):** Same emoji-safety rationale as Phase 23. Multi-byte chars must not split at a byte boundary.

### Pattern 5: D-17 Doctor Table (Per-Worktree Status)

**Data source pipeline:**

```rust
// 1. Roster: which worktrees exist?
//    git -C {seed} worktree list --porcelain
//    Output format (verified 2026-05-20):
//      worktree {abs_path}
//      bare                      ← only for seed itself
//    OR
//      worktree {abs_path}
//      HEAD {sha}
//      branch refs/heads/{name}
//      [blank line between entries]
// 2. For each non-bare worktree:
//    Clean/dirty check:  git -C {worktree} status --porcelain   (empty stdout = clean)
//    Unpushed count:     git -C {worktree} rev-list --count HEAD   (until Phase 35
//                        there's no upstream — count from root; OR if Phase 35
//                        already wired, count HEAD..@{upstream} negated → unpushed)
// 3. Reverse-map branch to (scope, name):
//    branch refs/heads/a-{name} → ("agent",   name)
//    branch refs/heads/p-{name} → ("project", name)
```

**Recommended rendering (matches existing `src/owl/doctor.rs` style — see `print_drain_summary` and `check_*` functions, single-line per-item with PASS/WARN/FAIL prefix):**

```
[PASS] tracked: seed initialized at psyches/tracked/seed
[PASS] tracked:agent:doyle     → a-doyle           → clean
[WARN] tracked:agent:zelyne    → a-zelyne          → dirty (2 modified)
[WARN] tracked:project:skill_owl → p-claude_skill_owl → 5 unpushed
[WARN] tracked: 1 orphan worktree (run --fix)
```

Use `git worktree prune --dry-run --verbose` for orphan reporting (output format: `Removing worktrees/{name}: gitdir file points to non-existent location` [VERIFIED 2026-05-20]).

**`git missing` case (D-02):** Single FAIL row: `[FAIL] tracked: git missing — tracked-dir not versioned (commune/signoff still functional)`.

### Pattern 6: Sessions Log JSONL Composer + Append

**Composer (pure function — unit-testable without fs):**

```rust
// Recommended struct:
#[derive(serde::Serialize)]
struct SessionEntry {
    ts: String,           // ISO-8601 UTC ("2026-05-20T08:00:00Z" — chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true))
    session_uuid: String,
    trigger: String,      // "boot" | "pulse" | "commune" | "signoff"
}

fn compose_session_line(ts: &str, session_uuid: &str, trigger: &str) -> String {
    let entry = SessionEntry {
        ts: ts.to_string(),
        session_uuid: session_uuid.to_string(),
        trigger: trigger.to_string(),
    };
    // serde_json::to_string preserves field order because Cargo.toml has
    // serde_json with `preserve_order` (verified via existing usage in
    // src/common/owlery.rs::append_project_history, line 519).
    let mut s = serde_json::to_string(&entry).expect("JSONL serialize");
    s.push('\n');  // line-terminated per JSONL spec
    s
}
```

**Append (append-only, idempotent on partial writes):**

```rust
// Append-only write — std::fs::OpenOptions::new().append(true).create(true)
// is the canonical idiom; no atomic-rename ceremony needed for append-only
// (writes are append-position-atomic up to PIPE_BUF on Unix; NTFS does not
// guarantee atomic append but partial JSONL lines remain forward-recoverable
// because each line is self-contained).
//
// For the truncate-on-roll (D-12) sealing path, atomic write IS required:
//   1. git -C {agent_wt} commit (commit current sessions.log via standard pipeline)
//   2. atomic_write_string(&sessions_path, "")  (reuse owlery::atomic_write_string,
//      already in src/common/owlery.rs:255 — same pattern, same NTFS-safe rename)
//   3. compose + append first new-gen entry
```

**Generation count source (D-12):** Read `info.json::generation` via existing `crate::live::context::read_status(self_id)` (returns `PsycheStatus { generation, ... }`). This is the SAME value `git_commit_context` uses today (`src/live/context.rs:314`). Do NOT count seal commits in git log — that would race with concurrent writes.

### Anti-Patterns to Avoid

- **`rm -rf` to clean up a worktree directory** — leaves orphan `seed/worktrees/{name}/` admin dir. Use `git worktree remove {path}` or, if the directory is already gone, `git worktree prune` to clear admin metadata.
- **`git commit --allow-empty` inside the bare repo** — fails with `fatal: this operation must be run in a work tree`. Use `commit-tree` + `update-ref` + `symbolic-ref` (Pattern 2).
- **Counting on `origin` being configured automatically** — worktrees from `git init --bare` have NO remote by default. Add explicit `git remote add origin {seed_path}` per worktree, or push by relative path (Pitfall 2).
- **Hand-rolling JSON for sessions.log** — fragile and error-prone. Use serde with `preserve_order`.
- **Trigger migration check from `start.rs`** — the plain `$OWL listen` path does NOT route through `start.rs`. Use lazy detection from `ensure_seed()`.
- **Block delivery on git failure** — every Phase 24 git op MUST soft-fail (D-02 + Phase 23 D-13). Payload landing on disk is the contract.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Subprocess soft-timeout | New timeout machinery in `tracked.rs` | `crate::common::git::run_git_with_timeout` (existing, `src/common/git.rs:302`) | Zombie-safe spawn+kill pattern with the right Windows `hide_window` invocation; already exercised by 3 Phase 23 test fixtures |
| Hostname capture for `Machine:` trailer | New hostname helper | `crate::common::git::stamp()` (existing) | Already returns hostname via env-var-then-shellout chain (`src/common/git.rs:249-278`) |
| Cwd-to-project-name resolution | New repo-detection helper | `crate::common::git::stamp()` (existing) — returns `Stamp { project, ... }` | Same — Phase 23 D-02 logic at `src/common/git.rs:283` |
| Subject truncation with multi-byte safety | Re-derive emoji-aware char slicing | `cap_subject_72` pattern (`src/common/git.rs:210`) scaled to 50 chars | Same emoji + multi-byte boundary handling already tested |
| Atomic file write on NTFS | Hand-roll temp+rename | `crate::common::owlery::atomic_write_string` (existing, `src/common/owlery.rs:255`) | Same-volume rename guarantee already documented for `$SPT_HOME` writes |
| ISO-8601 timestamp | Hand-roll `format!` | `chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)` | Already used across the codebase; matches existing `format_timestamp()` shape |
| Status output coloring | Hand-roll ANSI | `crate::common::output::*` helpers | Existing convention; matches `src/owl/doctor.rs` style |

**Key insight:** Phase 24 is almost entirely a *plumbing* phase — every primitive it needs already exists (Phase 23 subprocess wrapper, Phase 18.8 atomic-write helper, Phase 23 Stamp). The new code is the *orchestration* (`tracked.rs`), not the primitives.

## Runtime State Inventory

Phase 24 is a structural-restructure + migration phase. Every category needs an explicit answer.

| Category | Items Found | Action Required |
|----------|-------------|-----------------|
| **Stored data** | `$SPT_HOME/psyches/tracked/{id}.log`, `{id}.md`, `{id}-memformat.xml` (legacy flat layout, written by `src/live/context.rs::run_save` and `src/live/start.rs::relocate_previous_log` and `src/live/fork.rs`). Existing `.git` repo inside `psyches/tracked/` (initialized by `git_commit_context` at `src/live/context.rs:340-348`). | **Data migration** (D-15): move legacy files into `agents/{id}/` worktrees with new names (daemon.log / live_context.md / memformat.xml). **Code edit**: every writer (`run_save`, `relocate_previous_log`, `fork.rs`) updates to write under `agents/{id}/`. **Pitfall:** existing `.git` inside `psyches/tracked/` from Phase 23 era must be cleanly replaced (or repurposed) when `seed/` initializes — see Pitfall 6. |
| **Live service config** | None. SPT does not use external services with name-baked configs (no Datadog, no n8n, no Cloudflare). Phase 35 adds GitHub-remote config but that's out of scope. | None. |
| **OS-registered state** | None — wrapper PID files live under `$SPT_HOME/status/.psyche-wrapper-{id}.pid` (not OS-registered); no Task Scheduler, no launchd, no systemd. Binary handoff (Phase 18.4/18.5) coordinates via `installed_plugins.json` flips, NOT OS state. | None. |
| **Secrets / env vars** | `$SPT_HOME` env var (resolved to FS path) — Phase 24 honors existing resolution via `crate::common::owlery::spt_home()`. `$OWL` / `$LIVE` (set in `~/.claude/settings.json`) reference `owl.exe` path — Phase 24 doesn't change these. | None — env vars unchanged. |
| **Build artifacts / installed packages** | `owl.exe` binary itself (compiled output); plugin marketplace clone at `~/.claude/plugins/cache/cplugs/spt/` (DEPLOY.ps1 target); `psyche.md` embedded via `include_str!` (rebuild needed if changed — unrelated to Phase 24). | **Build action**: cargo build + DEPLOY.ps1 reinstall. No artifact name changes — `owl.exe` stays the same. |

**Canonical question — answered:** *After every file in the repo is updated, what runtime systems still have the old layout cached?*

Answer: only the flat-layout files at `$SPT_HOME/psyches/tracked/{id}.{log,md,xml}` on operator machines. D-15 migration handles them on first boot of v1.8. Idempotent. Coexistence-tolerant during Phase 18.4/18.5 binary handoff (a brief v1.7.1 sibling writing flat-layout files while v1.8 has already migrated → next migrate pass picks them up).

## Common Pitfalls

### Pitfall 1: Orphan Worktree Metadata Blocks Recreation

**What goes wrong:** Operator (or buggy code) `rm -rf`'s an agent worktree directory. Later code calls `git worktree add ../agents/{id} a-{id}` and gets:

```
fatal: 'a-{id}' is already used by worktree at 'C:/.../wtest/w3'
```

[VERIFIED 2026-05-20 — even after `rm -rf` of the worktree path, the bare repo's `worktrees/{name}/` admin dir still claims the branch.]

**Why it happens:** `git worktree add` registers metadata under `seed/worktrees/{name}/gitdir` pointing to the worktree path. Removing the worktree dir doesn't update this metadata.

**How to avoid:**
1. Document: Cleanup paths MUST use `git worktree remove {path}` (D-05 already locks this).
2. **Defensive: always run `git -C {seed} worktree prune` before `worktree add`** in `ensure_agent_worktree`/`ensure_project_worktree`. Prune is idempotent and cheap (single tree-walk over `seed/worktrees/`).

**Warning signs:** Migration repeated on the same agent. Manual operator cleanup. `worktree add` failing with "already used by worktree at" for a path that no longer exists.

### Pitfall 2: Worktrees from Bare Repos Have No `origin`

**What goes wrong:** `git -C {worktree} push origin {branch}` fails with:

```
fatal: 'origin' does not appear to be a git repository
```

[VERIFIED 2026-05-20 — worktrees inherit nothing remote-related from bare seed.]

**Why it happens:** `git init --bare seed` creates no remote configuration. `git worktree add` from a bare seed does not auto-add a remote pointing back at the bare repo.

**How to avoid:** **Two valid resolutions** — planner picks one and locks it:

| Option | Pros | Cons |
|--------|------|------|
| **A. Add `origin = ../../seed` per worktree on creation** | D-13 `git push origin {branch}` works literally. Phase 35 swap is one config edit per worktree. | Adds 1 git invocation per `worktree_add`. Phase 35 needs to update N remotes (one per worktree) — still O(N) but trivial. |
| **B. Skip `push` entirely until Phase 35** | Simpler. Commit-from-worktree shares objects directly with bare; the commit IS already in seed's object db [VERIFIED 2026-05-20]. Branch refs land in `seed/refs/heads/{branch}` directly. | Diverges from D-13 wording. Phase 35 must add the remote+push step later. |

**Researcher recommendation: Option A.** The D-13 contract says "push to seed"; the commit-is-already-there optimization is correct but fragile (relies on shared-objects internal). Option A makes the push step a real `git push` (no-op in steady-state, since the ref is already at the target SHA, but explicit). Phase 35's swap is `git -C {worktree} remote set-url origin {github_url}` per worktree — trivial loop.

### Pitfall 3: Git Refuses Commits Without `user.name` / `user.email`

**What goes wrong:** On a fresh operator machine with no git global config, `git commit` fails:

```
fatal: empty ident name ... not allowed
```

**Why it happens:** Many SPT operators won't have global `git config user.name`/`user.email` set.

**How to avoid:** **Every git commit in Phase 24 MUST pass `-c user.name=spt-bootstrap -c user.email=spt@local` via argv.** This:
- Sets the identity for THIS invocation only (no global config side-effect).
- Works regardless of operator's git setup.
- Matches the synthetic-author identity already chosen in CONTEXT.md Claude's Discretion.

Apply to: bootstrap commit (Pattern 2), every commune/signoff/echo/pulse commit (D-13), every migration commit (D-15), every sessions-log seal commit (D-12).

**Verification:** Live test on 2026-05-20 confirmed `git -c user.name=a -c user.email=a@b commit ...` succeeds without global config.

### Pitfall 4: Migration Idempotence + Handoff Race

**What goes wrong:** During Phase 18.4/18.5 binary handoff, a v1.7.1 wrapper writes a flat-layout `tracked/doyle.log` to disk milliseconds after a v1.8 sibling already migrated and removed legacy files. The v1.8 detector now sees `seed/` exists AND legacy files exist — does it re-migrate?

**Why it happens:** Migration trigger (`seed/` absent AND legacy present) does not catch newly-created legacy files when `seed/` is already present.

**How to avoid:**
- Detector logic = `if !seed/.exists() { migrate } else { migrate_orphaned_legacy }`. The "orphaned legacy" path is a second migration pass that handles newly-appeared flat-layout files even when `seed/` is already present. Same per-agent flow (move + commit), no bootstrap step.
- Migration commit subject for orphaned-legacy pass: `migrate: {id} — import legacy flat layout (orphan recovery)`.
- Both paths must run inside `tracked::ensure_seed()` so that EVERY `tracked::*` call point (write payload, append session, doctor scan) catches the race on its next invocation.

**Warning signs:** v1.8 deploy in mixed-version environment showing `agents/{id}/daemon.log` missing the last gen of pre-migration data.

### Pitfall 5: Pulse Trigger Origin is NOT `src/live/touch_loop.rs`

**What goes wrong:** Planner assumes "pulse" trigger entries fire from `src/live/touch_loop.rs` (matching CONTEXT.md "touch_loop.rs — emits pulse trigger entries" canonical-ref language).

**Why it happens:** `src/live/touch_loop.rs` is the **Touch monitor** — a Spine-spawned health-check loop that scans for dead Psyche perches every 5 minutes (`src/live/touch_loop.rs:58 run_touch_loop`). It does NOT initiate pulses; it monitors them.

**Where pulses ACTUALLY fire:** The Psyche wrapper's main loop fires periodic `claude -p --resume` sessions on its `period` interval. The fresh-session call is `src/live/wrapper/claude.rs::init_session` (no `--resume`) and the recurring call is `resume_session`/`resume_session_with_exit`/`resume_session_checked`. The wrapper main loop lives in `src/live/wrapper/mod.rs` and the per-tick logic threads through `src/live/wrapper/lifecycle.rs`. **`pulse` trigger entries fire from the wrapper's resume call site — NOT from `touch_loop.rs`.**

**How to avoid:** Planner must identify the correct trigger call sites in the wrapper:
- `boot` — `src/live/start.rs::run` (live start) AND `src/live/stop.rs::run_revive` (revive — fresh `claude -p` without `--resume`).
- `pulse` — wrapper's recurring resume call (in `src/live/wrapper/mod.rs` main loop OR `src/live/wrapper/claude.rs::resume_session`).
- `commune` — every echo_commune fire (`src/owl/echo_commune.rs::run_echo_commune` payload-write step, OR `src/live/wrapper/echo_fire.rs::fire_echo_commune_if_due`).
- `signoff` — `src/live/signoff.rs::run` and `signoff_result` (post-`compose_init_signoff_payload`).

**Action for plans:** Verify each trigger call site explicitly. Don't rely on the CONTEXT.md canonical-ref naming — it was written before the wrapper/touch distinction was clear.

### Pitfall 6: Phase 23 Era `.git` Already Lives Inside `psyches/tracked/`

**What goes wrong:** `src/live/context.rs::git_commit_context` (line 340-348) runs `git init` inside `psyches/tracked/` today (Phase 23 era flat layout). Operators upgrading to v1.8 already have a `.git/` directory at `$SPT_HOME/psyches/tracked/.git/`. Phase 24's `git init --bare $SPT_HOME/psyches/tracked/seed` does NOT conflict (different path), but the legacy flat-layout repo is now stranded.

**Why it happens:** Phase 23's `git_commit_context` versions the flat-layout files in-place. Phase 24 introduces a new bare repo at a subdirectory.

**How to avoid:**
- Migration (D-15) MOVES legacy files into worktrees but does NOT need to import the legacy `.git/`. The Phase 23 commit history is preserved in the legacy `.git/` dir, just orphaned. Plan should EXPLICITLY decide: (a) leave legacy `.git/` in place (deferred forensic resource), or (b) `rm -rf` it after successful migration. **Researcher recommendation: leave in place.** It's small, lives outside any worktree, and may have forensic value if migration goes sideways.
- After Phase 24 lands, `git_commit_context` is refactored to route through `tracked.rs` — the in-place `git init` line goes away. Plans should mark `git_commit_context` for refactor explicitly.

**Warning signs:** Operator running `git log` inside `psyches/tracked/` post-v1.8 and seeing the Phase 23 history — that's expected; it's the orphaned legacy repo.

### Pitfall 7: `git -C {worktree} status --porcelain` False Positives from Line Endings on Windows

**What goes wrong:** Live test 2026-05-20 showed:

```
warning: in the working copy of 'a.txt', LF will be replaced by CRLF the next time Git touches it
```

after writing an `\n`-terminated file on Windows. Git's `core.autocrlf` default on Windows converts. This can cause `git status --porcelain` to report a clean-looking file as dirty after re-read (or vice versa).

**How to avoid:** Set `core.autocrlf=false` on every worktree (or on the bare seed, which propagates):

```
git -C {seed} config core.autocrlf false
git -C {seed} config core.eol lf
```

Set at the same time as `gc.worktreePruneExpire never` (D-05) during `ensure_seed()` bootstrap. This keeps sessions.log, live_context.md, etc. as LF-terminated regardless of operator platform — matches existing convention (everything in `psyches/tracked/` is plain UTF-8 with LF line endings).

**Warning signs:** Doctor showing every worktree as "dirty" on Windows even with no edits.

## Code Examples

### Example 1: `tracked::ensure_seed()` — Idempotent Bootstrap

```rust
// Source: composition of patterns verified 2026-05-20
use std::path::{Path, PathBuf};

pub fn ensure_seed() -> std::io::Result<PathBuf> {
    let seed = crate::common::owlery::psyche_dir().join("seed");

    // Idempotent — fast path
    if seed.join("HEAD").exists() {
        // Still run prune to clean any orphans (Pitfall 1)
        let _ = run_git_quiet(&["-C", &seed.to_string_lossy(), "worktree", "prune"]);
        // Still run orphaned-legacy migration pass (Pitfall 4)
        migrate_legacy_if_needed(&seed)?;
        return Ok(seed);
    }

    // Cold path — full bootstrap
    std::fs::create_dir_all(&seed)?;
    run_git_quiet(&["init", "--bare", &seed.to_string_lossy()])?;
    run_git_quiet(&["-C", &seed.to_string_lossy(), "config", "gc.worktreePruneExpire", "never"])?;
    run_git_quiet(&["-C", &seed.to_string_lossy(), "config", "core.autocrlf", "false"])?;  // Pitfall 7
    run_git_quiet(&["-C", &seed.to_string_lossy(), "config", "core.eol", "lf"])?;

    // Empty tree → bootstrap commit → ref → HEAD (Pattern 2)
    let empty_tree = run_git_with_stdin(
        &["-C", &seed.to_string_lossy(), "hash-object", "-t", "tree", "--stdin"],
        b"",
    )?;
    let commit_sha = run_git_quiet(&[
        "-C", &seed.to_string_lossy(),
        "-c", "user.name=spt-bootstrap",
        "-c", "user.email=spt@local",
        "commit-tree", empty_tree.trim(),
        "-m", "init: tracked seed",
    ])?;
    run_git_quiet(&["-C", &seed.to_string_lossy(), "update-ref", "refs/heads/main", commit_sha.trim()])?;
    run_git_quiet(&["-C", &seed.to_string_lossy(), "symbolic-ref", "HEAD", "refs/heads/main"])?;

    // Migrate legacy flat files if any present (D-15)
    migrate_legacy_if_needed(&seed)?;

    Ok(seed)
}
```

### Example 2: `tracked::commit_and_push()` — Single Worktree Commit Pipeline

```rust
// D-13 write pipeline: add → commit → push
// All subprocess calls go through Phase 23 `run_git_with_timeout` for soft-fail.
pub fn commit_and_push(
    worktree: &Path,
    branch: &str,
    files: &[&str],         // paths relative to worktree
    subject: &str,          // D-07 shape
    trailers: &str,         // D-08 trailer block
) -> Result<(), TrackedError> {
    // git add
    for f in files {
        run_git_soft(&worktree, &["add", f])?;
    }

    // git commit (full message = subject + blank + trailers)
    let full_msg = if trailers.is_empty() {
        subject.to_string()
    } else {
        format!("{}\n\n{}", subject, trailers)
    };
    run_git_soft(&worktree, &[
        "-c", "user.name=spt-bootstrap",
        "-c", "user.email=spt@local",
        "commit", "--allow-empty-message",  // never block on empty body
        "-m", &full_msg,
    ])?;

    // git push (D-13 explicit push semantics; Pitfall 2 Option A)
    run_git_soft(&worktree, &["push", "origin", branch])?;

    Ok(())
}
```

### Example 3: Sessions Log Pure Composer

```rust
// Source: Pattern 6 — testable without filesystem
#[derive(serde::Serialize)]
struct SessionEntry<'a> {
    ts: &'a str,
    session_uuid: &'a str,
    trigger: &'a str,
}

pub fn compose_session_line(ts: &str, session_uuid: &str, trigger: &str) -> String {
    let entry = SessionEntry { ts, session_uuid, trigger };
    let mut s = serde_json::to_string(&entry).expect("session entry serializes");
    s.push('\n');
    s
}

// Unit test (pure — no fs, no env, no subprocess):
#[test]
fn session_line_locked_field_order() {
    let line = compose_session_line(
        "2026-05-20T08:00:00Z",
        "e0616fa0-1234-5678-9abc-def012345678",
        "pulse",
    );
    assert_eq!(
        line,
        r#"{"ts":"2026-05-20T08:00:00Z","session_uuid":"e0616fa0-1234-5678-9abc-def012345678","trigger":"pulse"}
"#
    );
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Phase 23 flat layout: `psyches/tracked/{id}.{log,md,xml}` versioned in-place by `git_commit_context`'s `git init` inside `psyches/tracked/` | Phase 24 bare seed + per-agent + per-project worktrees, prefixed branches | Phase 24 (this phase) | Disk shape, helper module split (`tracked.rs`), and all write call sites must be updated |
| Stamp surfaces in payload only (`event_attrs`, `yaml_frontmatter`) | Stamp surfaces ALSO in git commit trailers (`commit_trailers`) | Phase 24 | New `Stamp::commit_trailers()` renderer + per-scope variant |
| Per-fire git subprocess (Phase 23 `stamp()`) | Per-fire still, plus ~10-15 additional git invocations per commune/signoff | Phase 24 | All soft-fail; total budget per fire ≈ 7×500ms worst case (~3.5s wall before timeouts collapse). Acceptable for periodic pulse path; tighter for synchronous user paths. **Measure during execution.** |

**Deprecated / outdated:**
- `git_commit_context` (`src/live/context.rs:312`) — gets refactored to delegate to `tracked::commit_and_push`. Inline `git init`/`git add`/`git commit`/`git tag` block is replaced.
- Flat-layout writers in `src/live/start.rs::relocate_previous_log`, `src/live/fork.rs::run` — these need pathing updates to write under `agents/{id}/` post-migration.
- `.planning/codebase/STRUCTURE.md`, `CONVENTIONS.md`, `TESTING.md` are dated 2026-03-28 and describe a *bash-era* codebase. **They are not authoritative for Phase 24.** Source-of-truth is the `src/` tree itself.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Operator git ≥ 2.5 (worktree support) — covered by Windows Git for Windows default + macOS/Linux defaults | Standard Stack | Low — Pre-2.5 git would also fail Phase 23, so any v1.8 user already has compatible git |
| A2 | `serde_json` with `preserve_order` feature is already enabled | Pattern 6 | Need to verify — see existing usage at `src/common/owlery.rs:519` (works there) but planner should grep `Cargo.toml` for `preserve_order` |
| A3 | Synthetic identity `spt-bootstrap <spt@local>` is acceptable to user — taken from CONTEXT.md Claude's Discretion guidance | Pattern 2 | Low — user surfaced this exact recommendation in CONTEXT.md |
| A4 | All Phase 24 git invocations can tolerate 500ms timeout per Phase 23 D-13 | All git ops | Medium — `git worktree add` may exceed 500ms on first run on slow disks. Migration with N agents may compound. **Planner should consider a higher timeout (e.g., 2000ms) for `worktree add` and migration commits specifically**, or accept the partial-write contract. |
| A5 | Sessions log truncate-on-roll is safe under concurrent write (only one wrapper writes per agent at a time) | Pattern 6 | Low — single wrapper per agent is a hard invariant of the live-agent system (Phase 18.5 handoff explicitly serializes). |
| A6 | Pitfall 2 Option A (add `origin` per worktree) is the chosen disposition | Pitfall 2 | High if planner chooses Option B without re-evaluation. **Lock during plan-phase explicitly.** |
| A7 | Stamp's `Project` field omission for project worktrees does NOT break psyche-download drift detection (Phase 23 D-08 `<psyche-stamp/>` block) | D-08 / Pattern 1 | Low — psyche-download reads stamp from YAML front-matter of `live_context.md` (an *agent* artifact), which carries all 5 fields. Project worktree commits surface in `git log`, not in `<psyche-stamp/>`. No drift-detection regression. |

## Open Questions (RESOLVED)

1. **Should `git push origin {branch}` happen on every commit, or batched/throttled?** RESOLVED — superseded by D-13 amendment: NO push in Phase 24 (worktrees share seed object DB + refs; commit is immediately visible). Phase 35 will add push + origin config. Question moot.
   - What we know: D-13 specifies push per commit. Phase 23 D-13 soft-fail extends here.
   - What's unclear: Pulse path fires every `period` seconds (default 1200s = 20min). Commune+echo path fires more often. Is per-commit push acceptable, or should pushes batch?
   - Recommendation: **per-commit push for v1.8.** Until Phase 35 wires the GitHub remote, push is a fast no-op against the local bare. If Phase 35 measurements show latency issues, add a coalescing layer there.

2. **Where do the `boot` trigger entries fire for `$LIVE revive`?** RESOLVED — planner reads `src/live/stop.rs::run_revive` and inserts `append_session_entry(id, "boot")` at the equivalent place to `run`/`live_start_result` post-wrapper-spawn. Plan 24-04 Task 2 wires both `start.rs` + `stop.rs::run_revive` boot sites.

3. **Does the legacy `.git/` inside `psyches/tracked/` need migration?** RESOLVED — leave orphaned `.git/` in place (forensic resource). Document in migration commit message. Doctor adds WARN row if detected: `[WARN] legacy .git dir present at psyches/tracked/.git (Phase 23 history orphaned; safe to delete)`.

4. **Does the project-scope write path exist yet in v1.8?** RESOLVED — Phase 24 builds the `ensure_project_worktree` primitive ONLY; no caller invokes it until Phase 25. SC3's "lazy creation on first project-scoped write" is unobservable in Phase 24 because no project-scoped write happens. Plans cover this with a unit test exercising `ensure_project_worktree("foo")` end-to-end (Plan 24-02 includes `ensure_project_worktree_creates_p_prefix_branch`). 24-07 CHANGELOG must explicitly document SC3 deferral to Phase 25.

5. **What's the agent worktree → branch mapping when `agents/{id}/` already exists from a partial run?** RESOLVED — `ensure_agent_worktree("doyle")` checks `agents/doyle/.git` first: if it's a regular file pointing to `seed/worktrees/doyle/`, the worktree is registered, return early. Only run `worktree add` if missing OR if metadata is stale (caught by `git worktree prune`).

6. **What is the canonical `session_uuid` source for emitters at start.rs, stop.rs::run_revive, wrapper/claude.rs (resume + 24h refresh + handoff-rehydration), echo_commune.rs, and signoff.rs?** RESOLVED (locked during plan-phase iter 1 to remove Phase 24.1 deferral path):
   - The wrapper writes `wrapper-state.json` at boot/rehydration containing `session_uuid` (a fresh UUID generated each fresh `claude -p` spawn). All emitter sites read it via the existing wrapper-state pathway.
   - `boot` emit (start.rs + stop.rs::run_revive): emit the new session_uuid AFTER wrapper spawn returns and wrapper-state.json is written. Read via `read_wrapper_state(agent_id) -> WrapperState { session_uuid, .. }`.
   - `pulse` emit (wrapper/claude.rs `resume_session*` + wrapper/mod.rs 24h-refresh + handoff-rehydration): the wrapper IS the writer; it already holds `session_uuid` in-memory as a local — pass directly to `append_session_entry`.
   - `commune` emit (echo_commune.rs): echo_commune runs inside the wrapper context; read session_uuid from wrapper-state.json (or from in-memory wrapper handle if passed as a parameter).
   - `signoff` emit (signoff.rs): signoff runs from the wrapper's pre-exit hook; read from wrapper-state.json at the same point `compose_init_signoff_payload` runs.
   - If `wrapper-state.json` lacks `session_uuid` field today: Plan 24-04 Task 1 adds it as a new field to the `WrapperState` struct (it is generated at wrapper start anyway — just exposed). NO Phase 24.1 deferral path; if the field is missing it gets added in Phase 24 as part of the same plan.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `git` CLI | All Phase 24 git invocations | ✓ (verified on host machine) | 2.43.0.windows.1 | **D-02: degrade silently** — payload writes still happen to disk; no version control; doctor reports `git missing` |
| Rust toolchain (cargo) | Build | ✓ | (existing) | — |
| `serde_json` crate (already in deps) | Sessions log serialization | ✓ (existing) | (existing) | — |
| `chrono` crate (already in deps) | Timestamp formatting | ✓ (existing) | (existing) | — |
| `tempfile` crate (already in deps) | Test fixtures | ✓ (existing) | (existing) | — |

**Missing dependencies with no fallback:** None.

**Missing dependencies with fallback:** `git` (D-02 covers the missing-git case end-to-end).

## Validation Architecture

`.planning/config.json` was not located via standard probes. Phase 24 init context indicated `nyquist_validation_enabled: false` per the additional_context. This section is **SKIPPED** per the orchestrator's directive. Normal verification criteria still apply via standard plan-checker review.

That said, the planner should consider these test surfaces (informal — not Nyquist-formatted):

- **Unit (pure functions, in-module):**
  - `cap_short_50` truncation + emoji-safety (mirrors `cap_subject_72` test pattern in `src/common/git.rs:411`).
  - `compose_commit_subject` shape for every `kind`.
  - `Stamp::commit_trailers(TrailerScope::Agent)` — all 5 trailers, escape ordering.
  - `Stamp::commit_trailers(TrailerScope::Project)` — 4 trailers, `Project` omitted.
  - `commit_trailers` with `None` branch/head_sha/head_subject — omitted (D-11 parity).
  - `compose_session_line` — field-order lock + JSON valid.
  - Branch reverse-map: `a-doyle` → `(Agent, "doyle")`, `p-claude_skill_owl` → `(Project, "claude_skill_owl")`.
- **Unit (tempdir-based fs):**
  - `ensure_seed()` idempotence — call twice, second call no-op.
  - `ensure_agent_worktree("doyle")` end-to-end — bare init + worktree creation + commit + push.
  - `migrate_legacy_if_needed` — pre-seed legacy files, assert post-state has worktree + commits + no legacy files.
  - `append_session_entry` truncate-on-roll: append 3 entries, fire `boot` trigger, assert seal commit + truncated file + first new-gen entry.
  - Pitfall 1 — `rm -rf` worktree, re-call `ensure_agent_worktree`, assert prune+recreate succeeds.
  - Pitfall 3 — invoke `commit_and_push` on a machine with no global git config, assert commit succeeds (synthetic identity).
- **Golden (`tests/golden_live.rs` style):**
  - `$LIVE doctor` output with mixed clean/dirty/unpushed worktrees.
  - Migration summary line: `migrated N agents to forked layout`.
  - Sessions-log seal commit subject format.
- **Integration (`tests/handoff_integration.rs` style):**
  - Full live-start → commune → signoff round trip, assert agent worktree commits land.
  - Generation rollover via `$LIVE revive`, assert seal commit + new sessions.log line.
  - Cross-binary handoff (Phase 18.4/18.5 pattern) with migration during handoff, assert idempotence.

**Test fixture pattern (recommended):** Reuse `isolated_home()` from `tests/golden_live.rs:22` — sets `SPT_HOME` to a tempdir. Add a sibling helper `seed_with_worktrees(home, agents: &[&str])` that builds a minimal seed + N agent worktrees for `$LIVE doctor` / migration tests.

## Security Domain

Per the orchestrator's directive (`security_enforcement: false` would surface in init JSON; presumed enabled). Phase 24 surface attack analysis:

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|------------------|
| V2 Authentication | no | No auth surface — local filesystem only |
| V3 Session Management | no | Sessions log is a write-only audit artifact, not an auth session |
| V4 Access Control | no | Single-user local state (`$SPT_HOME` is per-user) |
| V5 Input Validation | **yes** | `self_id`, `project_name`, `session_uuid`, commit body fields all flow into git CLI argv |
| V6 Cryptography | no | No crypto in this phase |

### Known Threat Patterns for the Phase 24 stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Argv injection via `self_id`/`project_name`/`branch_name` | Tampering | All git invocations use Rust `Command::arg()` / `args()` — argv-shape only, never shell-evaluated. Confirmed pattern at `src/common/git.rs:303-313`. |
| Path traversal via `self_id` (e.g., `../etc`) | Tampering | `psyche_dir().join("agents").join(self_id)` — `Path::join` does not canonicalize. Add a `validate_id_chars` predicate (`[a-zA-Z0-9_-]+`) to `tracked.rs` and reject anything else. **Parallel: Phase 32 already validates this for owlery perch IDs — check whether the validator is reusable.** |
| Commit body injection via user-controlled text | Tampering | `cap_short_50` truncation already strips embedded newlines via `.lines().next()` (Phase 23 pattern). Trailer block uses `Key: value` with capitalized keys — git parser rejects malformed trailers (visible as `git log --format=%(trailers)` will not split them out, but they don't break parsing). Defensive: strip `\n` from trailer values before format. |
| Symlink-following during migration | Tampering | `fs::rename` on Windows follows symlinks; on Unix renames the link itself. **Migration MUST verify legacy files are regular files (`metadata.file_type().is_file()`) before move.** |
| Git config injection via env var (e.g., `GIT_CONFIG_PARAMETERS`) | EoP | Phase 24 inherits parent env — including any malicious `GIT_*` vars set by upstream. **Recommend: scrub `GIT_*` env vars from `Command` before every git invocation, OR document the trust boundary.** Lower priority — operator owns the spt host. |

### Stack-specific notes

- All Rust subprocess calls use `Command::arg()` / `args()` exclusively (verified via grep across `src/`). No shell-string interpolation.
- File writes use `fs::write` / `OpenOptions::append`; no `format!` into shell.
- Atomic-write helper (`atomic_write_string`) uses `fs::rename` which is per-OS atomic on same-volume — same trust boundary as Phase 18.8.

## Sources

### Primary (HIGH confidence)

- **Live test on git 2.43.0.windows.1 (2026-05-20):** Verified bare-repo bootstrap fails with `commit --allow-empty`; verified `commit-tree`+`update-ref`+`symbolic-ref` works; verified worktree-add from bare; verified worktree+seed object sharing; verified `git worktree prune` resolves orphan-blocking-recreate; verified `gc.worktreePruneExpire never` config syntax.
- **`src/common/git.rs` (Phase 23, in tree):** `Stamp` struct, `event_attrs`, `yaml_frontmatter`, `run_git_with_timeout`, `cap_subject_72`. All patterns Phase 24 reuses or extends.
- **`src/common/owlery.rs` (in tree):** `psyche_dir()`, `atomic_write_string`, `derive_current_repo_names`, `append_project_history`. All helpers Phase 24 calls.
- **`src/live/context.rs::git_commit_context` (`src/live/context.rs:312-423`):** Existing in-place `git init`/commit pipeline that Phase 24 refactors.
- **`src/live/start.rs::relocate_previous_log` (`src/live/start.rs:42`):** Existing flat-layout writer for `{id}.log` — must update for SC 4.
- **`src/live/fork.rs` (whole file):** Existing `{id}-memformat.xml` handling — must update for SC 4.
- **`src/owl/doctor.rs` (whole file):** Existing doctor pattern Phase 24's per-worktree status extends.
- **`.planning/phases/24-tracked-dir-forked-repo-layout-agents-projects-branches-sess/24-CONTEXT.md`:** Locked user decisions D-01..D-17.
- **`.planning/phases/23-commune-signoff-project-root-head-sha-stamping/23-CONTEXT.md`:** Phase 23 Stamp shape, soft-fail posture, D-11 omission policy.
- **`.planning/seeds/SEED-004-stamp-params-in-psyche-commit-messages.md`:** Folded into D-08; trailer field set verified.
- **`.planning/ROADMAP.md` §Phase 24 (lines 410-448):** 11 Success Criteria; canonical layout block.

### Secondary (MEDIUM confidence)

- **git-scm.com docs (CITED, not fetched in this session):** `git worktree`, `git commit-tree`, `git config gc.worktreePruneExpire`, `git interpret-trailers`. Public API behavior cross-referenced with live test.

### Tertiary (LOW confidence)

- None. All recommendations are either VERIFIED by live test or derived from in-tree code that was directly read.

## Metadata

**Confidence breakdown:**

- Standard stack: **HIGH** — no new packages; everything is system git + existing crate deps.
- Architecture: **HIGH** — three critical git CLI behaviors verified by live test on the target platform (Windows git 2.43); existing code touchpoints all read.
- Pitfalls: **HIGH** — Pitfalls 1-3 verified live on 2026-05-20; Pitfalls 4-7 derived from explicit reading of in-tree code + CONTEXT.md / Phase 23 history.
- Migration design: **MEDIUM** — Pitfall 4 race scenario is theoretical (no live test on Phase 18.4/18.5 handoff scenario possible without two binaries). Mitigation (orphaned-legacy pass) is defensive but may need integration test.
- Doctor surface: **MEDIUM** — output format is recommendation; exact polish is planner's call per CONTEXT.md discretion.

**Research date:** 2026-05-20
**Valid until:** 2026-06-20 (30 days — stable domain; only churn vector is git CLI breaking changes which are exceedingly rare on stable branches).
