# Phase 24.1: Tracked-Agents info.json - Pattern Map

**Mapped:** 2026-05-20
**Files analyzed:** 12 modified, 0 net-new source files (one runtime artifact: `psyches/tracked/agents/{id}/info.json`)
**Analogs found:** 12 / 12 (every touchpoint has a precise in-tree analog)

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `src/common/types.rs` (MODIFY: new structs + field-type upgrade) | model | schema | `src/common/types.rs::InfoJson` lines 30-62 | exact (same module, same pattern) |
| `src/common/owlery.rs` (MODIFY: new helpers + signature change) | service/utility | file-I/O CRUD | `owlery::append_project_history` lines 566-616 + `atomic_write_string` lines 299-306 | exact |
| `src/common/git.rs` (MODIFY: hostname visibility + new `head_branch_or_empty`) | utility | shell-out request-response | `git::hostname()` lines 326-355 | exact |
| `src/common/tracked.rs` (MODIFY: migration synth in `migrate_legacy_if_needed`) | service | batch transform + commit | `tracked::migrate_legacy_if_needed` lines 1329+ + `tracked::append_session_entry` lines 963-1010 | exact |
| `src/common/time.rs` (MODIFY: promote `now_iso` → `now_iso_utc`) | utility | pure transform | `tracked::now_iso` lines 942-944 (private) | role-match (promote-in-place) |
| `src/owl/poll.rs` (MODIFY: call-site upgrade @ ~135) | controller | event-driven | existing `append_project_history` call site lines 128-135 | exact (extend in place) |
| `src/live/start.rs` (MODIFY: call-site upgrade @ ~280 + boot bump @ 446) | controller | event-driven | existing `append_project_history` call site lines 275-281 | exact |
| `src/live/context.rs` (MODIFY: `run_save` / `run_amend_signoff` commit-files amendment) | controller | request-response + commit | `live::context::run_save` lines 323-388 | exact |
| `src/live/signoff.rs` (MODIFY: `emit_signoff_trigger` add bump) | controller | event-driven | `signoff::emit_signoff_trigger` lines 48-69 | exact |
| `src/owl/echo_commune.rs` (MODIFY: commune trigger emit site amendment) | controller | event-driven | echo_commune lines 637-661 | exact |
| `src/owl/doctor.rs` (MODIFY: per-worktree sub-line in `check_tracked_layout`) | controller | read-only diagnostic | `doctor::check_tracked_layout` lines 405-507 | exact |
| `src/owl/list.rs` + `src/live/list.rs` (MODIFY: tracked-first reader) | controller | read-only | `owlery::enumerate_perches` lines 370-396 | role-match |

`runtime artifact: psyches/tracked/agents/{id}/info.json` — same on-disk role as `owlery/{id}/info.json`; reads through `owlery::tracked_agent_info_path()` (new) parallel to `owlery::info_file()` lines 174-177.

## Pattern Assignments

### `src/common/types.rs` (model, schema)

**Analog:** `src/common/types.rs::InfoJson` (lines 30-62, same module).

**Imports pattern** (lines 1-6 — already present, no additions):
```rust
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
```

**Struct shape pattern** (lines 30-62 — copy this exact derive set + `#[serde(default)]` + `skip_serializing_if` discipline):
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfoJson {
    pub owl_id: String,
    pub started: String,
    pub mode: String,
    pub pid: PidValue,
    pub session_id: String,
    pub state: PerchState,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    // ...
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub project_history: Vec<String>,  // <-- CHANGE to Vec<ProjectHistoryEntry>
}
```

**Legacy-tolerant deserialize pattern** (line 60-61): `#[serde(default)]` lets older bodies deserialize cleanly; `skip_serializing_if = "Vec::is_empty"` minimizes write-amp. Re-apply to the new `Vec<ProjectHistoryEntry>` field type.

**Roundtrip test pattern** (lines 263-333):
```rust
#[test]
fn cwd_field_roundtrips() { ... }

#[test]
fn cwd_field_deserializes_when_missing() {
    let legacy = r#"{ "owl_id": "legacy", ... no_cwd_key ... }"#;
    let parsed: InfoJson = serde_json::from_str(legacy).unwrap();
    assert!(parsed.cwd.is_none(), "missing cwd should deserialize to None");
}
```
Copy this exact form for new tests on `TrackedAgentInfo::last_project_name = None` + legacy `project_history: Vec<String>` deserialization.

---

### `src/common/owlery.rs` (service, file-I/O CRUD)

**Analog:** `owlery::append_project_history` lines 566-616 (the upgrade-in-place target) + `atomic_write_string` lines 299-306 (the D-12 primitive) + `info_file` lines 174-177 (the path-resolver sibling).

**Atomic write primitive** (lines 299-306 — REUSE verbatim for tracked info.json writes):
```rust
pub fn atomic_write_string(path: &std::path::Path, body: &str) -> std::io::Result<()> {
    let tmp = path.with_file_name(format!(
        "{}.tmp",
        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
    ));
    fs::write(&tmp, body)?;
    fs::rename(&tmp, path)
}
```

**Path resolver pattern** (lines 174-177 — copy for new `tracked_agent_info_path`):
```rust
pub fn info_file(id: &str) -> PathBuf {
    perch_dir(id).join("info.json")
}
// NEW sibling — same shape:
pub fn tracked_agent_info_path(agent_id: &str) -> PathBuf {
    agent_worktree_path(agent_id).join("info.json")
}
```

**Value round-trip mutate-in-place pattern** (lines 566-616 — UPGRADE IN PLACE):
```rust
pub fn append_project_history(perch_id: &str, names: &[String]) {
    if names.is_empty() { return; }
    let info_path = info_file(perch_id);
    let content = match std::fs::read_to_string(&info_path) {
        Ok(c) => c,
        Err(_) => return,                // soft-fail per D-02
    };
    let mut info: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return,
    };
    let obj = match info.as_object_mut() {
        Some(o) => o,
        None => return,
    };
    // ... lookup, dedup, append ...
    let mut changed = false;
    for n in names {
        if !existing.contains(n) {
            // ... push ...
            changed = true;
        }
    }
    if !changed { return; }              // <-- KEY write-amp guard (line 606-608)
    obj.insert("project_history".to_string(), serde_json::Value::Array(history));
    if let Ok(updated) = serde_json::to_string(&info) {
        let _ = std::fs::write(&info_path, updated);
    }
}
```

**The upgrade:** signature gains `branch: &str`; return type gains `bool` (`true` = file changed, callers add `info.json` to git-add slice); inner loop calls a new shared `append_history_entry(arr, name, branch_opt, now)` primitive. Per RESEARCH Pitfall 5, run `normalize_legacy_strings_to_objects(&mut history, &now)` BEFORE the dedup loop.

**Best-effort silent-on-error posture** (every `match ... { Err(_) => return }` arm at lines 571, 575, 614): the new `bump_tracked_agent_info` MUST match this posture — return `false` on any failure path, never panic, never propagate.

**Read-only consumer pattern** (lines 370-396 — `enumerate_perches`, copy for `read_tracked_agent_info_or_fallback`):
```rust
pub(crate) fn enumerate_perches() -> Vec<(String, crate::common::types::InfoJson)> {
    let dir = owlery_dir();
    let mut out = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(_) => return out,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        let info_path = path.join("info.json");
        let content = match std::fs::read_to_string(&info_path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let info: crate::common::types::InfoJson = match serde_json::from_str(&content) {
            Ok(i) => i,
            Err(_) => continue,
        };
        // ...
        out.push((id, info));
    }
    out
}
```
Same "skip-on-error" iteration pattern works for the listings reader; for the single-agent reader, collapse to `Option::and_then` chain (typed-struct round-trip is fine for READS per RESEARCH anti-pattern note).

**Path-helper pattern** (lines 93-123 — agent_worktree_path, agent_branch, etc. — `pub fn` returning `PathBuf` with NO directory creation). Mirror for `tracked_agent_info_path`.

---

### `src/common/git.rs` (utility, shell-out request-response)

**Analog:** `git::hostname()` lines 326-355 (the visibility-promotion target + the shape template for `head_branch_or_empty`).

**Visibility change** (line 326):
```rust
fn hostname() -> String { ... }
// CHANGE to:
pub(crate) fn hostname() -> String { ... }
```

**Shell-out + hide-window + env-first fallback pattern** (lines 326-355 — copy for `head_branch_or_empty`):
```rust
fn hostname() -> String {
    #[cfg(windows)]
    let env_var = "COMPUTERNAME";
    #[cfg(unix)]
    let env_var = "HOSTNAME";

    if let Ok(v) = std::env::var(env_var) {
        if !v.is_empty() { return v; }
    }

    let mut cmd = Command::new("hostname");
    crate::common::process::hide_window(&mut cmd);
    if let Ok(out) = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .output()
    {
        if out.status.success() {
            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !s.is_empty() { return s; }
        }
    }
    "unknown".to_string()
}
```

**New helper (`head_branch_or_empty`) follows this template** but:
- No env-var fast-path (HEAD is repo-state, not env).
- Use `git symbolic-ref --short -q HEAD` (NOT `rev-parse --abbrev-ref HEAD`) per RESEARCH Pitfall 1 — `symbolic-ref -q` exits non-zero on detached HEAD instead of returning the literal `"HEAD"` token.
- Args: `["-C", cwd_str, "symbolic-ref", "--short", "-q", "HEAD"]`.
- Return `""` on any failure (no-git, detached, no-repo) — empty string by D-05 contract.
- ALWAYS call `crate::common::process::hide_window(&mut cmd)` for the Windows CREATE_NO_WINDOW guard.

---

### `src/common/tracked.rs` (service, batch + commit)

**Analog (migration extension):** `tracked::migrate_legacy_if_needed` starting line 1329 (per-agent loop) + `tracked::append_session_entry` lines 963-1010 (the `ensure_agent_worktree` + `atomic_write_string` template).

**Per-agent loop template** (lines 1295-1346 — pattern for the synth step):
```rust
thread_local! {
    static MIGRATION_IN_PROGRESS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

pub fn migrate_legacy_if_needed(seed_existed_before: bool) -> Result<usize, TrackedError> {
    if MIGRATION_IN_PROGRESS.with(|f| f.get()) { return Ok(0); }
    MIGRATION_IN_PROGRESS.with(|f| f.set(true));
    struct Reset;
    impl Drop for Reset {
        fn drop(&mut self) { MIGRATION_IN_PROGRESS.with(|f| f.set(false)); }
    }
    let _reset = Reset;
    // ... per-agent loop ...
}
```
The re-entry guard ALREADY covers Phase 24.1 synth — the synth runs inside this exact loop, gated by the same flag.

**Ensure-worktree-then-atomic-write pattern** (lines 975-1010):
```rust
pub fn append_session_entry(agent_id: &str, session_uuid: &str, trigger: &str) -> Result<(), TrackedError> {
    if !validate_id_chars(agent_id) { ... }
    if !validate_trigger(trigger) { ... }

    // Materialize the worktree (lazy per D-16).
    ensure_agent_worktree(agent_id)?;

    let path = owlery::agent_worktree_path(agent_id).join("sessions.log");
    let raw = std::fs::read_to_string(&path).unwrap_or_default();
    let mut entries = parse_session_lines(&raw);

    // ... mutate entries in place ...

    let body: String = entries.iter().map(|e| compose_session_line(...)).collect();
    owlery::atomic_write_string(&path, &body).map_err(TrackedError::Io)?;
    Ok(())
}
```
This is the EXACT template for `bump_tracked_agent_info` per RESEARCH Pitfall 4 (Option 1): call `ensure_agent_worktree(agent_id)` internally before the write so the helper is robust against call-site reordering.

**Commit funnel** (lines 1119-1185, `commit_payload` + `commit_agent_payload` + `commit_agent_payload_with_timeout`): UNCHANGED. New files just appear in the `files: &[&str]` slice. Verified: `commit_payload` does `git add` then `git commit` with no file-name filtering (line 1129-1131). Adding `"info.json"` to the slice is a one-token edit per call site.

**Migration commit literal subject pattern** (lines 1278-1282): `"migrate: {id} — import legacy flat layout"`. Phase 24.1 does NOT introduce a new subject — info.json piggybacks on the SAME commit.

**ISO timestamp formatter** (lines 942-944 — promote to shared module):
```rust
fn now_iso() -> String {
    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
```
Move to `src/common/time.rs` as `pub(crate) fn now_iso_utc()`; replace the private duplicate here with a `use crate::common::time::now_iso_utc;` import. Both `tracked::append_session_entry` AND the new Phase 24.1 helpers then share the byte-identical formatter (RESEARCH Pitfall 2 + Pitfall 6).

---

### `src/common/time.rs` (utility, pure transform)

**Analog:** existing `format_timestamp` lines 8-17 (local-time formatter — do NOT modify; the new helper sits next to it).

**Existing pattern** (NOT to be used for first_seen/last_seen — RESEARCH Pitfall 6):
```rust
pub fn format_timestamp() -> String {
    let now = Local::now();
    // ... returns "2026-05-20 11:14:31 PST" (local, wrong format for D-05)
}
```

**New helper** (promote from `tracked::now_iso`):
```rust
/// RFC-3339 UTC seconds-precision with `Z` suffix: "2026-05-20T08:00:00Z".
/// Used by Phase 24 sessions.log AND Phase 24.1 tracked info.json
/// first_seen / last_seen / last_started fields. Promoted from
/// `tracked::now_iso()` for crate-wide reuse (RESEARCH Pitfall 6).
pub(crate) fn now_iso_utc() -> String {
    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
```
Test must pin byte format `"YYYY-MM-DDTHH:MM:SSZ"` — no fractional seconds, no `+00:00` (use `Z`).

---

### `src/owl/poll.rs` (controller, event-driven)

**Analog:** in-place modification of lines 128-135 (the current `append_project_history` call site).

**Current call site** (lines 128-135):
```rust
// Phase 32 D-05: append current repo names to project_history AFTER the
// info.json write — `InfoJson::new` is a constructor that resets
// project_history to Vec::new(), so the write above clobbers any prior
// history. The Value-round-trip helper reads the fresh file, dedupes,
// and writes back any new names. No-op-safe on empty Vec (cwd unavailable
// or no .git ancestor + no cwd basename). See RESEARCH §Pitfall 1.
let repo_names = owlery::derive_current_repo_names();
owlery::append_project_history(id, &repo_names);
```

**Upgrade pattern** (RESEARCH Pattern 3 — bump THEN compose file list):
```rust
let repo_names = owlery::derive_current_repo_names();
let branch = crate::common::git::head_branch_or_empty(&std::env::current_dir().unwrap_or_default());
owlery::append_project_history(id, &repo_names, &branch);   // upgraded signature
// NOTE: $OWL listen does NOT commit through tracked pipeline; tracked-side bump
// only fires from the live-side call sites (start.rs / context.rs / signoff.rs /
// echo_commune.rs). Poll.rs only upgrades the perch-side helper signature.
```
Per RESEARCH §Pattern 2 / Lifecycle Hook Map: `$OWL listen` (poll.rs) is NOT a tracked-info-bump trigger — the agent perches owned by `owl listen` are not live-agent perches with a worktree. Only the perch-side `append_project_history` upgrade applies here.

---

### `src/live/start.rs` (controller, event-driven)

**Analog:** in-place modification of lines 275-281 (reconnect path) + line 446 area (boot-trigger emit site).

**Current reconnect call site** (lines 275-281):
```rust
// Phase 32 D-05: append current repo names to project_history AFTER the
// info.json write. ... See RESEARCH §Pitfall 1.
let repo_names = owlery::derive_current_repo_names();
owlery::append_project_history(id, &repo_names);
```

**Upgrade pattern** (RESEARCH Pattern 3 — boot trigger version):
```rust
let repo_names = owlery::derive_current_repo_names();
let branch = crate::common::git::head_branch_or_empty(&std::env::current_dir().unwrap_or_default());
owlery::append_project_history(id, &repo_names, &branch);   // perch side

// NEW (tracked side): boot trigger updates last_started + last_machine_name
// + machine_history. Boot does NOT touch project fields (D-06 + D-09).
// Per RESEARCH Open Question #3 (locked): pass empty slice + empty branch.
let info_changed = owlery::bump_tracked_agent_info(id, "boot", &[], "");
// On boot path, the tracked commit happens via the next commune; the bump
// alone writes info.json atomically. If a boot-context commit is firing here,
// extend its `files` slice with "info.json" when info_changed == true.
```

**Boot-trigger emit site** (line 446 area — `emit_boot_trigger_after_spawn`): MUST run AFTER `append_session_entry(..., "boot")` per RESEARCH §Pattern 2. Single-line addition: `let _ = owlery::bump_tracked_agent_info(id, "boot", &[], "");`.

---

### `src/live/context.rs` (controller, request-response + commit)

**Analog:** `live::context::run_save` lines 323-388 — THE canonical pattern for the commit-files amendment.

**Current pattern** (lines 365-381):
```rust
// Phase 24 D-13: commit through the tracked pipeline. Memformat is
// committed alongside when present so a single commit captures the
// "context-save" snapshot.
let memformat_present = wt.join(MEMFORMAT_FILE).exists();
let mut files: Vec<&str> = vec![LIVE_CONTEXT_FILE];
if memformat_present {
    files.push(MEMFORMAT_FILE);
}
let subject = tracked::compose_commit_subject(
    "context-save", self_id, "live_context", &body,
);
if let Err(e) = tracked::commit_agent_payload(self_id, &files, &subject) {
    warn_tracked_commit_failed(self_id, &e);
}
```

**Upgrade pattern** (RESEARCH §Code Examples 3 — bump BEFORE files-vec extension):
```rust
// NEW: bump tracked info.json before composing the file list.
// Soft-fail per D-02: bump failures return false and the commit proceeds
// without info.json (payload is the guaranteed contract).
let names = owlery::derive_current_repo_names();
let branch = crate::common::git::head_branch_or_empty(&std::env::current_dir().unwrap_or_default());
let info_changed = owlery::bump_tracked_agent_info(self_id, "commune", &names, &branch);

let memformat_present = wt.join(MEMFORMAT_FILE).exists();
let mut files: Vec<&str> = vec![LIVE_CONTEXT_FILE];
if memformat_present { files.push(MEMFORMAT_FILE); }
if info_changed { files.push("info.json"); }  // NEW

let subject = tracked::compose_commit_subject(
    "context-save", self_id, "live_context", &body,
);
if let Err(e) = tracked::commit_agent_payload(self_id, &files, &subject) {
    warn_tracked_commit_failed(self_id, &e);
}
```

**Apply same pattern to:** `run_amend_signoff` (3 other call sites of `commit_agent_payload` in this file follow the same shape).

---

### `src/live/signoff.rs` (controller, event-driven)

**Analog:** `signoff::emit_signoff_trigger` lines 48-69 (the `append_session_entry` pattern).

**Current pattern** (lines 48-69):
```rust
fn emit_signoff_trigger(self_id: &str, psyche_id: &str) {
    match crate::common::wrapper_state::read_wrapper_state(psyche_id) {
        Some(state) if !state.session_uuid.is_empty() => {
            if let Err(e) = crate::common::tracked::append_session_entry(
                self_id, &state.session_uuid, "signoff",
            ) {
                eprintln!("WARNING: sessions log signoff append failed for {}: {} (continuing)",
                    self_id, e);
            }
        }
        _ => {
            eprintln!("WARNING: wrapper-state.json missing or empty session_uuid for signoff of {}; sessions.log row skipped",
                self_id);
        }
    }
}
```

**Upgrade pattern** — add bump after the successful append branch:
```rust
fn emit_signoff_trigger(self_id: &str, psyche_id: &str) {
    match crate::common::wrapper_state::read_wrapper_state(psyche_id) {
        Some(state) if !state.session_uuid.is_empty() => {
            if let Err(e) = crate::common::tracked::append_session_entry(
                self_id, &state.session_uuid, "signoff",
            ) {
                eprintln!("WARNING: sessions log signoff append failed for {}: {} (continuing)", self_id, e);
            }
            // NEW: bump tracked info.json (signoff trigger).
            let names = crate::common::owlery::derive_current_repo_names();
            let branch = crate::common::git::head_branch_or_empty(
                &std::env::current_dir().unwrap_or_default(),
            );
            let _ = crate::common::owlery::bump_tracked_agent_info(self_id, "signoff", &names, &branch);
            // NOTE: signoff does not commit through tracked pipeline here —
            // the atomic write lands; next commune commits info.json.
            // If signoff DOES trigger a commit elsewhere, extend that
            // commit's files slice with "info.json" when bump returned true.
        }
        _ => { /* unchanged */ }
    }
}
```

**Soft-fail posture** (matches the existing `let Err(e) = ...` ignore-pattern): bump failures return `false`; `let _ = ...` discards both error and bool. Payload delivery is never blocked.

---

### `src/owl/echo_commune.rs` (controller, event-driven)

**Analog:** echo_commune lines 637-661 — the commune trigger emit site.

**Current pattern** (lines 649-661):
```rust
// Phase 24 Plan 04 — sessions.log commune trigger (D-11). The
// session_uuid is the function argument passed in by the wrapper
// at fire time, NOT a state-file read (Q6 LOCKED — no
// read_wrapper_state needed because the wrapper already holds
// the UUID in-memory and forwards it here). D-18 dedup means
// repeated commune fires for the same UUID update ts in place.
// Soft-fail per D-02.
let _ = crate::common::tracked::append_session_entry(
    self_id, session_uuid, "commune",
);
```

**Upgrade pattern** — add bump after the append (per RESEARCH §Code Examples 3 note: the COMMIT happens in `context.rs::run_save`, NOT here; this site only writes info.json atomically without staging it):
```rust
let _ = crate::common::tracked::append_session_entry(
    self_id, session_uuid, "commune",
);
// NEW: bump tracked info.json — commune trigger. Atomic-write only;
// the actual git-commit pickup happens in the next context.rs::run_save
// (which extends its files slice with "info.json" when bump returned true).
let names = crate::common::owlery::derive_current_repo_names();
let branch = crate::common::git::head_branch_or_empty(
    &std::env::current_dir().unwrap_or_default(),
);
let _ = crate::common::owlery::bump_tracked_agent_info(self_id, "commune", &names, &branch);
```

---

### `src/owl/doctor.rs` (controller, read-only diagnostic)

**Analog:** `doctor::check_tracked_layout` lines 405-507 — the D-14 sub-line lands inside the per-worktree loop at lines 442-464.

**Current per-worktree row** (lines 442-464):
```rust
tracked::WorktreeScope::Agent | tracked::WorktreeScope::Project => {
    let scope_tag = match row.scope {
        tracked::WorktreeScope::Agent => "agent",
        tracked::WorktreeScope::Project => "project",
        tracked::WorktreeScope::Bare => unreachable!(),
    };
    let (status_enum, state_str) = match &row.state {
        tracked::WorktreeState::Clean => (DiagStatus::Pass, "clean".to_string()),
        tracked::WorktreeState::Dirty { modified } => (
            DiagStatus::Warn,
            format!("dirty ({} modified)", modified),
        ),
        tracked::WorktreeState::LocalOnly { commits } => {
            (DiagStatus::Pass, format!("{} local", commits))
        }
    };
    results.push(DiagResult {
        name: format!("tracked:{}:{}", scope_tag, row.name),
        status: status_enum,
        detail: format!("{} → {} → {}", row.name, row.branch, state_str),
    });
}
```

**Output rendering pattern** (line 41):
```rust
eprint!("  {color}[{tag}]\x1b[0m {}: {}\n", r.name, r.detail);
```
ANSI-colored, stderr-only, no separate width budget. Per RESEARCH Pitfall 8: recommended multi-line layout via newlines inside `detail` (or push 2 extra `DiagResult` rows with empty `name` for indented sub-lines — planner's call).

**Upgrade pattern** (D-14 sub-line, agent-scope only — projects do NOT get the activity line):
```rust
// Inside the Agent branch only:
if matches!(row.scope, tracked::WorktreeScope::Agent) {
    // Best-effort read; absent file → skip sub-line cleanly.
    if let Some(summary) = owlery::read_tracked_agent_info_or_fallback(&row.name) {
        results.push(DiagResult {
            name: "tracked".to_string(),  // empty/blank name for sub-line
            status: DiagStatus::Pass,
            detail: format!(
                "  last_started={}, last_machine={}, last_project={}",
                summary.last_started,
                summary.last_machine_name,
                summary.last_project_name.as_deref().unwrap_or("-"),
            ),
        });
        results.push(DiagResult {
            name: "tracked".to_string(),
            status: DiagStatus::Pass,
            detail: format!("  path={}", owlery::tracked_agent_info_path(&row.name).display()),
        });
    }
}
```

**Snapshot test extension** (per RESEARCH Pitfall 8): extend `check_tracked_layout_one_agent_clean` test at doctor.rs:628 to assert the sub-line presence + format.

---

### `src/owl/list.rs` + `src/live/list.rs` (controller, read-only)

**Analog:** `owlery::enumerate_perches` lines 370-396 — read-only iteration with `match ... { Err(_) => continue }` skip-on-error.

**Pattern for tracked-first reader with perch fallback** (D-15):
```rust
// New helper (in owlery.rs):
pub fn read_tracked_agent_info_or_fallback(perch_id: &str) -> Option<TrackedAgentInfoSummary> {
    let tracked_path = tracked_agent_info_path(perch_id);
    // 1. Try tracked-side first (typed-struct round-trip is fine for READS).
    if let Ok(content) = std::fs::read_to_string(&tracked_path) {
        if let Ok(info) = serde_json::from_str::<crate::common::types::TrackedAgentInfo>(&content) {
            return Some(TrackedAgentInfoSummary {
                last_started: info.last_started,
                last_machine_name: info.last_machine_name,
                last_project_name: info.last_project_name,
            });
        }
    }
    // 2. Fallback: perch info.json (transient agents pre-first-commit).
    let perch_path = info_file(perch_id);
    let content = std::fs::read_to_string(&perch_path).ok()?;
    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
    let last_started = v.get("started").and_then(|s| s.as_str()).unwrap_or_default().to_string();
    let last_machine_name = crate::common::git::hostname();
    let last_project_name = v.get("project_history")
        .and_then(|h| h.as_array())
        .and_then(|arr| arr.first())
        .and_then(|first| {
            // Tolerate BOTH legacy String entries AND new Object entries.
            first.as_str().map(String::from)
                .or_else(|| first.get("name").and_then(|n| n.as_str()).map(String::from))
        });
    Some(TrackedAgentInfoSummary { last_started, last_machine_name, last_project_name })
}
```
The mixed-shape probe on the fallback path is critical — listings may run against perches that have both legacy String entries AND new Object entries during the rollout window (RESEARCH Pitfall 5).

---

## Shared Patterns

### Soft-Fail Silent-on-Error Posture (every new helper)

**Source:** `owlery::append_project_history` lines 571-578, 614 (`return` on every `Err(_)`); `signoff::emit_signoff_trigger` line 48-69 (`let Err(e) = ...` log + continue); `echo_commune.rs:656` (`let _ = ...append_session_entry...`); `live::context::run_save` line 379 (`warn_tracked_commit_failed`).

**Apply to:** ALL new helpers (`bump_tracked_agent_info`, `read_tracked_agent_info_or_fallback`, `head_branch_or_empty`, `append_history_entry`, `normalize_legacy_strings_to_objects`, `tracked_agent_info_path` itself is pure).

**Rule:** Payload landing on disk is the guaranteed contract; tracked info.json freshness is a best-effort durability layer. Every error path returns the type's "neutral" value (`false`, `None`, `""`, `Vec::new()`).

```rust
// Canonical shape:
let content = match std::fs::read_to_string(&info_path) {
    Ok(c) => c,
    Err(_) => return false,           // or None / "" / Vec::new()
};
let mut info: serde_json::Value = match serde_json::from_str(&content) {
    Ok(v) => v,
    Err(_) => return false,
};
```

### Value Round-Trip Mutate-in-Place (any write helper)

**Source:** `owlery::append_project_history` lines 566-616 — Phase 32 D-08 precedent.

**Apply to:** `bump_tracked_agent_info`, `append_project_history` (upgraded), `append_history_entry`, `normalize_legacy_strings_to_objects`.

**Rule:** Reads use `serde_json::Value` (not typed struct) so unknown fields survive the round-trip. Writes serialize the mutated `Value` back. `Cargo.toml` has `serde_json` with `preserve_order` so declaration-order survives. Typed-struct round-trip is fine for READS (listings, doctor) since those don't write back.

### Atomic Write (every tracked-side write)

**Source:** `owlery::atomic_write_string` lines 299-306.

**Apply to:** `bump_tracked_agent_info` (D-12 contract), migration synth (Pattern 6), AND optionally upgrade `append_project_history`'s perch-side write (currently `std::fs::write` at line 614; RESEARCH Open Question #1 recommends YES for symmetry).

```rust
owlery::atomic_write_string(&info_path, &body).is_ok()   // returns bool for bump
// or
owlery::atomic_write_string(&path, &body).map_err(TrackedError::Io)?   // returns Result
```

### `ensure_agent_worktree` Before Any Write (Pitfall 4 mitigation)

**Source:** `tracked::append_session_entry` line 978.

**Apply to:** `bump_tracked_agent_info` (FIRST step inside the helper) and the migration synth path.

```rust
// Materialize the worktree (lazy per D-16). Soft-fail per D-02:
// git-missing leaves the dir as a plain folder without .git.
let _ = crate::common::tracked::ensure_agent_worktree(agent_id);
```

### `hide_window` Guard on Every git Shell-Out (Windows console-flash)

**Source:** `owlery::try_remote_origin_basename` line 491; `git::hostname` line 339.

**Apply to:** `git::head_branch_or_empty` (the new helper).

```rust
let mut cmd = std::process::Command::new("git");
crate::common::process::hide_window(&mut cmd);
```

### Commit Funnel File-List Amendment (Pattern 3 / Phase 24 D-13)

**Source:** `live::context::run_save` lines 368-381.

**Apply to:** 5 call sites — `live/context.rs::run_save`, `live/context.rs::run_amend_signoff`, `live/start.rs` (reconnect + boot), `live/signoff.rs`, `owl/echo_commune.rs` (commune trigger). Migration synth path in `tracked.rs::migrate_legacy_if_needed`.

```rust
let info_changed = owlery::bump_tracked_agent_info(self_id, "<trigger>", &names, &branch);
let mut files: Vec<&str> = vec![PRIMARY_PAYLOAD_FILE];
// ... existing co-modified files ...
if info_changed { files.push("info.json"); }   // NEW
let subject = tracked::compose_commit_subject(...);
if let Err(e) = tracked::commit_agent_payload(self_id, &files, &subject) {
    warn_tracked_commit_failed(self_id, &e);
}
```

### Test Cluster Extension Pattern (Phase 32 test cluster)

**Source:** `src/common/owlery.rs` lines 1213-1316 — the Phase 32 cluster (the documented extension target per CONTEXT Claude's Discretion bullet 6).

**Apply to:** All new perch-shape migration tests + tracked info.json synth + mixed-shape array tests.

```rust
#[test]
fn append_project_history_creates_field_and_dedupes() {
    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let _snap = EnvSnapshot::capture();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());

    write_test_perch("ph_dedupe", std::process::id());

    // First append: creates the array.
    append_project_history("ph_dedupe", &["foo".to_string()]);
    let snapshot1 = std::fs::read_to_string(info_file("ph_dedupe")).unwrap();
    assert!(snapshot1.contains("\"project_history\""));
    assert!(snapshot1.contains("\"foo\""));

    // Second append of same name: byte-identical (no-op write-amp guard).
    append_project_history("ph_dedupe", &["foo".to_string()]);
    let snapshot2 = std::fs::read_to_string(info_file("ph_dedupe")).unwrap();
    assert_eq!(snapshot1, snapshot2);
}
```

**Required boilerplate per test:** `ENV_LOCK` mutex (env-var serialization), `EnvSnapshot::capture` (auto-restore), `tempfile::tempdir`, `SPT_HOME` override, `write_test_perch` helper (already present in the test cluster).

**Per RESEARCH A5 (MEDIUM-risk):** Planner MUST add at least one test that seeds a mixed-shape `project_history` array (one bare `String` + one `Object` for the same name) and asserts `bump_tracked_agent_info` produces a clean post-state with exactly one entry per name.

---

## No Analog Found

Every Phase 24.1 touchpoint has a precise in-tree analog. No "fresh ground" files exist — Phase 24.1 is pure composition over Phase 23/24/32 primitives.

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| (none) | — | — | — |

---

## Metadata

**Analog search scope:** `src/common/` (types, owlery, tracked, git, time), `src/owl/` (poll, echo_commune, doctor, list), `src/live/` (start, context, signoff, list), plus `Cargo.toml` for dep verification.

**Files scanned:** 12 source files + 1 manifest.

**Strongest analogs (3 highest-leverage):**
1. `owlery::append_project_history` (lines 566-616) — both the upgrade-in-place target AND the dedup-or-append-with-write-amp template for the new `bump_tracked_agent_info`.
2. `tracked::append_session_entry` (lines 963-1010) — the `ensure_agent_worktree` + `atomic_write_string` + soft-fail template, mirrored verbatim by the new helper.
3. `live::context::run_save` (lines 323-388) — the commit-funnel file-list-amendment pattern, copied to 5 call sites for the `if info_changed { files.push("info.json"); }` addition.

**Pattern extraction date:** 2026-05-20
