# Phase 24.1: Tracked-Agents info.json — Cross-Session Activity Index - Research

**Researched:** 2026-05-20
**Domain:** Rust persistence + JSON schema migration + git-worktree commit pipeline integration
**Confidence:** HIGH

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

All 15 decisions (D-01..D-15) are locked. Verbatim from `24.1-CONTEXT.md`:

- **D-01 (carried):** Machine identity = OS hostname via `crate::common::git::hostname()`. Per-machine UUID / gh-account email alternatives explicitly Phase 35 territory.
- **D-02 (carried):** Project identity = name string only. Reuse `crate::common::owlery::derive_current_repo_names()`. When folder ≠ origin, BOTH names appended as separate `project_history` entries. No `root` (abs path), no `remote_url`. ROADMAP schema draft's `root` is dropped.
- **D-03 (carried):** Write piggybacks on Phase 24 D-13. No new commit triggers. Every commune/signoff/echo/migrate cycle that hits the agent worktree includes `info.json` in the same commit if its contents changed.
- **D-04 (carried):** git CLI shell-out (no git2-rs). Missing-git fallback (Phase 24 D-02): degrade silently — tracked info.json STILL gets written as a raw file, just uncommitted.
- **D-05:** BOTH perch info.json AND tracked info.json carry the richer record shape.
  - `MachineHistoryEntry { name, first_seen, last_seen }`
  - `ProjectHistoryEntry { name, branch, first_seen, last_seen }`
  - Perch `project_history` upgraded from `Vec<String>` to `Vec<ProjectHistoryEntry>`. Both sides identical shape.
  - `branch` semantics: captured at write time via `git -C {cwd} rev-parse --abbrev-ref HEAD`. Updated on every bump (parallels `last_seen`). Empty string `""` on no-git/detached HEAD/no-repo. Migration of legacy `Vec<String>` entries: each string `s` deserializes to record with `branch=""`, `first_seen=<info.json mtime>`, `last_seen=<info.json mtime>`. Mixed arrays tolerated during rollout. Use `serde_json::Value` mutate-in-place pattern (current `owlery.rs:566` pattern extended).
- **D-06:** `last_started` updates on the `boot` trigger ONLY (Phase 24 D-11). Pairs 1:1 with `boot` rows in `sessions.log`. `pulse`/`commune`/`signoff` do NOT bump it.
- **D-07:** `last_machine_name` updates on every tracked write (commune/signoff/boot — any Phase 24 D-13 write to the agent worktree). Read directly from `hostname()` at write time.
- **D-08:** `machine_history` rules — scan for `name == hostname()`. If found → bump `last_seen` to now. Else → append `{name, first_seen: now, last_seen: now}`. `last_seen` bumps on every write (one info.json write per Phase 24 D-13 commit cycle).
- **D-09:** `last_project_name` updates on commune/signoff. Resolves via `derive_current_repo_names()` at write time. When 2 names returned (folder ≠ origin), FIRST element wins (folder basename — matches Phase 32 D-03 ordering). Both names still get appended via D-10.
- **D-10:** `project_history` rules — capture branch once per write via `git -C {cwd} rev-parse --abbrev-ref HEAD`. For each name in `derive_current_repo_names()`, scan for matching `name` (dedup key = `name` only — branch NOT part of key): Found → bump `last_seen` AND overwrite `branch`. Else → append `{name, branch, first_seen: now, last_seen: now}`. Phase 32 helper `owlery::append_project_history(perch_id, &names)` upgraded in place to write new record shape, signature gains `branch: &str` parameter. Tracked-side sibling helper writes into the agent worktree's info.json on same call site. Both helpers stay best-effort silent-on-error.
- **D-11:** Both histories unbounded; insertion order preserved (matches Phase 32 D-08). `first_seen` set ONCE on insertion; `last_seen` bumps on every subsequent sighting. No LRU. No cap.
- **D-12:** Atomic write via temp-file + rename. Reuse `crate::common::owlery::atomic_write_string` (already the pattern for perch info.json writes via `wrapper_state::write_atomic`-style sibling helper).
- **D-13:** Synthesize from perch info.json + mtime fallbacks. First boot of new binary, for each agent already on disk: read perch's `project_history` (may be legacy or upgraded), build tracked info.json with `agent_id` from perch's `owl_id`, `last_started` = perch info.json mtime, `last_machine_name` = `hostname()` at migration time, `last_project_name` = first element of perch's history if non-empty else `null` (`#[serde(skip_serializing_if="Option::is_none")]`), `machine_history` = single entry `{name: hostname(), first_seen: <mtime>, last_seen: <mtime>}`, `project_history` = for every string `s` in perch's history emit `{name: s, branch: "", first_seen: <mtime>, last_seen: <mtime>}`. Migration is idempotent (presence of tracked info.json short-circuits). Tracked info.json write goes through Phase 24 D-15 migration commit subject; no separate Phase 24.1 migrate commit.
- **D-14:** `$LIVE doctor` surfaces parsed summary + raw path. Per-agent one-line summary: `{agent_id}: last_started={ts}, last_machine={name}, last_project={name}` plus a line for `{path}`. Extends Phase 24 D-17 doctor output (`src/owl/doctor.rs`). No new table; same per-worktree row gains a sub-line.
- **D-15:** Expose tracked info.json path to consumers. `$LIVE psyche-download` and listings gain access to the file via new helper `owlery::tracked_agent_info_path(agent_id)`. Listings (`$LIVE list`, `$OWL list`) read tracked info.json `last_started`/`last_machine_name`/`last_project_name` directly (no perch parse). When tracked info.json absent (very fresh agents pre-first-commit), fall back to perch info.json fields. Psyche-download payload shape itself NOT changed in Phase 24.1 (Phase 25 territory).

### Claude's Discretion

- Exact struct names for new types (`HistoryEntry`, `TrackedAgentInfo`, etc.) — researcher / planner pick.
- Whether perch + tracked write paths share a single `append_history_entry(&mut Value, name, now)` primitive in `common/owlery.rs` or each calls its own.
- Exact owlery helper name for tracked info.json read/write — parallel to `info_file()` / `read_info_json()` precedent.
- Doctor sub-line formatting (column widths, dim/bold).
- Whether `last_project_name` is `String` or `Option<String>` in the Rust struct — `Option<String>` recommended.
- Test coverage breakdown — perch-shape migration tests should extend the existing Phase 32 test cluster in `common/owlery.rs:1182+`.

### Deferred Ideas (OUT OF SCOPE)

- LRU cap on machine_history / project_history (revisit on real growth).
- Richer project identity (`root` abs path, git-remote URL hash) — Phase 35.
- Per-machine UUID alternative to hostname — Phase 35.
- Cross-machine concurrent write conflict policy for tracked info.json — Phase 35.
- Doctor visual polish beyond MVP — future cosmetic work.
- psyche-download payload embedding of last_started / last_machine / last_project — Phase 25.
- Sessions log ↔ tracked info.json `last_started` consistency check in doctor — additive future doctor enhancement.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| TRK-INFO-SCHEMA-01 | Per-agent persistent JSON schema with the 5 top-level fields + machine_history / project_history element shapes | §Standard Stack (chrono, serde_json `preserve_order`), §Architecture Patterns Pattern 1 (TrackedAgentInfo struct + HistoryEntry types), §Code Examples 1 |
| TRK-INFO-LIFECYCLE-01 | `last_started` bumps on `boot`, `last_machine_name` on every write, `last_project_name` on commune/signoff | §Architecture Patterns Pattern 2 (lifecycle hook map), §Architecture Patterns Pattern 3 (commit-piggyback ordering), §Component Responsibilities table |
| TRK-INFO-HISTORY-01 | machine_history dedup-by-name + bump-or-append, project_history dedup-by-name + branch-overwrite-on-bump, unbounded insertion-order | §Architecture Patterns Pattern 4 (dedup-or-append primitive), §Code Examples 2, §Pitfall 4 (atomic update sequencing) |
| TRK-INFO-SYNC-01 | Atomic write semantics, write piggybacks on Phase 24 D-13 commit, doctor + listings + psyche-download expose | §Architecture Patterns Pattern 5 (write-path call ordering), §Code Examples 3, §Component Responsibilities table |
| TRK-INFO-MIGRATE-01 | First-boot synth from perch info.json + mtime fallbacks, mixed-shape array tolerance, idempotent | §Architecture Patterns Pattern 6 (migration synth), §Code Examples 4, §Pitfall 5 (mixed-shape arrays), §Architecture Patterns "Migration synth" |
</phase_requirements>

## Summary

Phase 24.1 is a **pure-additive Rust persistence change** layered on top of two already-shipped systems: Phase 24's bare-seed + worktree commit pipeline (`src/common/tracked.rs::commit_agent_payload`) and Phase 32's `Vec<String>` perch `project_history` (`src/common/owlery.rs::append_project_history`). The phase upgrades both sides — perch and tracked — to a richer `Vec<HistoryEntry>` shape carrying timestamps + branch context, and adds a brand-new file `psyches/tracked/agents/{agent_id}/info.json` that records cross-session metadata (`last_started`, `last_machine_name`, `last_project_name` + the two history arrays).

No new commit triggers, no new commands. Every commune/signoff/echo write that already goes through `tracked::commit_agent_payload(self_id, &files, &subject)` adds `"info.json"` to its `files` slice when the tracked-side helper detects a change. The Phase 32 `serde_json::Value` mutate-in-place pattern extends naturally to the new element type — legacy `Vec<String>` entries and new `Vec<Object>` entries coexist in the same array during rollout, with the helper rewriting legacy strings to objects on first touch.

**Primary recommendation:** Build one cross-cutting primitive `append_history_entry(arr: &mut Vec<Value>, name: &str, branch: Option<&str>, now: &str) -> bool` in `src/common/owlery.rs` and reuse it from BOTH `append_project_history` (upgraded signature: `(perch_id, names, branch)`) AND the new `append_tracked_project_history` (sibling that writes into the agent worktree's info.json). The primitive returns `true` if anything changed so callers can skip the write when the shape is byte-identical (Phase 32 write-amp guard).

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Schema types (`TrackedAgentInfo`, `HistoryEntry`) | `src/common/types.rs` | — | Existing home for `InfoJson` and `PerchState`; pure data structures with serde derives. |
| Identity sources (hostname, repo names, ISO timestamp) | `src/common/git.rs` + `src/common/owlery.rs` | — | `hostname()` already lives in `git.rs:326`; `derive_current_repo_names()` already lives in `owlery.rs:447`. Reuse, do not duplicate. ISO-UTC timestamp helper currently a private fn in `tracked.rs::now_iso()` — promote or duplicate. |
| Read/write of tracked info.json file | `src/common/owlery.rs` | — | Existing home for path helpers (`info_file`, `tracked_root`, `agent_worktree_path`) and for the Phase 32 `append_project_history`. New tracked-side sibling helpers (`tracked_agent_info_path`, `read_tracked_agent_info`, `append_tracked_machine_history`, `append_tracked_project_history`) live next door. |
| Atomic write primitive | `src/common/owlery.rs::atomic_write_string` | — | Already canonical (`owlery.rs:299`). Reuse verbatim. |
| Commit pipeline (info.json added to file list) | `src/live/context.rs` + `src/live/start.rs` + `src/owl/echo_commune.rs` + `src/live/signoff.rs` | `src/common/tracked.rs` | Five existing call sites of `commit_agent_payload(self_id, files, subject)` each grow `"info.json"` in their `files` slice when the tracked-side helper indicates a change. The Phase 24 commit funnel itself is unchanged. |
| Migration synth | `src/common/tracked.rs::migrate_legacy_if_needed` extension | `src/common/owlery.rs` (synth fn) | Phase 24's `migrate_legacy_if_needed` (line 1329) already runs per-agent inside the migrate commit. Phase 24.1 adds a synth-from-perch step inside the per-agent loop so the migrate commit includes the synthesized info.json. |
| Doctor surface | `src/owl/doctor.rs::check_tracked_layout` extension | `src/common/tracked.rs` (data source) | Phase 24 D-17 already emits per-worktree rows; D-14 adds a sub-line. Either inline-format inside `check_tracked_layout` or add a `tracked::read_agent_info_summary(agent_id) -> Option<AgentInfoSummary>` data source consumed there. |
| Listings consumption | `src/owl/list.rs` + `src/live/list.rs` | `src/common/owlery.rs` (tracked-first reader) | D-15: a helper `read_tracked_agent_info_or_fallback(perch_id)` returns the three durable fields; listings read it directly without re-parsing perch info.json. |

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `serde` | 1.0 (already in tree) | `Serialize`/`Deserialize` derives for `TrackedAgentInfo`, `MachineHistoryEntry`, `ProjectHistoryEntry` | Existing pattern (`types::InfoJson` already uses it). [VERIFIED: Cargo.toml] |
| `serde_json` | 1.0 with `preserve_order` feature | `Value` round-trip mutate-in-place pattern + locked field-order serialization | Already enabled in `Cargo.toml:11`. `preserve_order` is the prerequisite for the Phase 32 mutate-in-place pattern AND for the locked-field-order contract used by `sessions.log` (`tracked.rs:884`). [VERIFIED: Cargo.toml, src/common/owlery.rs:566] |
| `chrono` | 0.4 with `clock` + `std` (already in tree) | RFC-3339 UTC timestamp formatter (`Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)`) for `first_seen` / `last_seen` | Already pulled in for `time::format_timestamp` AND for `tracked::now_iso()` (`tracked.rs:942`). The ISO-8601 UTC form (`2026-05-20T08:00:00Z`) is what `sessions.log` already emits and what the locked CONTEXT schema expects. [VERIFIED: Cargo.toml:13, src/common/tracked.rs:942] |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `tempfile` | 3 (dev-deps) | Per-test tempdir for the new perch-shape migration + tracked-info-synth tests | Mirror Phase 32 cluster pattern: `tempfile::tempdir().unwrap()` + `SPT_HOME` env override + `ENV_LOCK` mutex guard. [VERIFIED: Cargo.toml:25] |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `serde_json::Value` mutate-in-place | Typed-struct round-trip via `serde_json::from_str::<TrackedAgentInfo>` | Typed round-trip drops unknown fields (no forward-compat). Phase 32 anti-pattern guidance explicitly rejects this for any file that may be touched by both an older and newer binary during binary handoff. Stay with Value round-trip. |
| `chrono::Utc::now().to_rfc3339_opts()` | Inline `time::format_timestamp()` | `format_timestamp()` returns local time with `PST`/`PDT`/`PT` label (NOT ISO-8601 UTC). Use this only for compatibility with the `started` field on perch info.json (Phase 23 carryover); use the chrono RFC-3339 form for `first_seen` / `last_seen`. See §Pitfall 6. |
| New `info.json` helper module | Extend `src/common/owlery.rs` | Owlery already owns `info_file`, `read_info_json` pattern; splitting would scatter the schema-write surface. Keep tracked-side helpers next to perch-side helpers. |

**Installation:** No new dependencies. All required crates already present in `Cargo.toml`.

**Version verification:** Performed via direct read of `Cargo.toml`. All deps (`serde`, `serde_json`, `chrono`, `tempfile`) are in-tree and pinned by the workspace.

## Package Legitimacy Audit

> No new external packages introduced by this phase. Existing in-tree crates only. Skipping the slopcheck gate.

| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---------|----------|-----|-----------|-------------|-----------|-------------|
| (none added) | — | — | — | — | — | — |

**Packages removed due to slopcheck [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none

## Architecture Patterns

### System Architecture Diagram

```
┌──────────────────────────────────────────────────────────────────────────┐
│  CALLER SITES (5)                                                        │
│  ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐  │
│  │ live/start.rs        │ │ live/context.rs      │ │ owl/echo_commune │  │
│  │ ::run (boot)         │ │ ::run_save           │ │ .rs (commune)    │  │
│  │ ::reconnect          │ │ ::run_amend_signoff  │ │                  │  │
│  └──────────┬───────────┘ └──────────┬───────────┘ └────────┬─────────┘  │
│             │                        │                       │            │
│  ┌──────────▼────────────────────────▼───────────────────────▼────────┐  │
│  │ live/signoff.rs::run    /    live/fork.rs::fork_files_only        │  │
│  └──────────────────────────────┬──────────────────────────────────────┘  │
└─────────────────────────────────┼─────────────────────────────────────────┘
                                  │ (each adds "info.json" to files slice
                                  │  WHEN the new helper indicates change)
                                  ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  PHASE 24 COMMIT FUNNEL — unchanged                                      │
│  tracked::commit_agent_payload(self_id, files, subject)                  │
│    → ensure_agent_worktree(id)                                           │
│    → commit_payload(wt, files, subject, TrailerScope::Agent, 500ms)      │
│      → git add (each file)                                               │
│      → git commit -c user.name=spt-bootstrap ...                         │
└──────────────────────────────────────────────────────────────────────────┘
                                  ▲
                                  │ (before calling commit_agent_payload,
                                  │  caller invokes the new helpers below)
                                  │
┌──────────────────────────────────────────────────────────────────────────┐
│  NEW HELPERS (Phase 24.1) — live in src/common/owlery.rs                 │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐  │
│  │ bump_tracked_agent_info(                                           │  │
│  │   agent_id, trigger,                  // "boot"|"commune"|"signoff"│  │
│  │   names: &[String],                   // from derive_current_repo… │  │
│  │   branch: &str)                       // captured HEAD             │  │
│  │ -> bool (true = info.json changed, include in commit)              │  │
│  │                                                                    │  │
│  │   1. resolve tracked_agent_info_path(agent_id)                     │  │
│  │   2. read_or_synthesize(path)  // Value round-trip                 │  │
│  │   3. if trigger == "boot": set last_started = now_iso()            │  │
│  │   4. set last_machine_name = hostname() (every trigger)            │  │
│  │   5. dedup-or-append machine_history with hostname()               │  │
│  │   6. if !names.empty(): set last_project_name = names[0]           │  │
│  │      AND dedup-or-append-with-branch each name in names            │  │
│  │   7. atomic_write_string(path, serialized) iff changed             │  │
│  └────────────────────────────────────────────────────────────────────┘  │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐  │
│  │ append_project_history(perch_id, &names, branch)  // UPGRADED SIG  │  │
│  │   shares the dedup-or-append-with-branch primitive                 │  │
│  └────────────────────────────────────────────────────────────────────┘  │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐  │
│  │ tracked_agent_info_path(agent_id) -> PathBuf                       │  │
│  │   = agent_worktree_path(agent_id).join("info.json")                │  │
│  └────────────────────────────────────────────────────────────────────┘  │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐  │
│  │ read_tracked_agent_info_or_fallback(agent_id)                      │  │
│  │   -> Option<(last_started, last_machine, last_project)>            │  │
│  │   used by $OWL list / $LIVE list / doctor                          │  │
│  └────────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────┘
                                  ▲
                                  │
┌─────────────────────────────────┼─────────────────────────────────────────┐
│  IDENTITY SOURCES — reused unchanged                                     │
│  • git::hostname() (private fn at git.rs:326 — needs promotion to pub(crate))│
│  • owlery::derive_current_repo_names() (owlery.rs:447)                   │
│  • git -C {cwd} rev-parse --abbrev-ref HEAD (new shell-out helper)       │
│  • chrono::Utc::now().to_rfc3339_opts(Secs, true) (or promote now_iso)   │
└──────────────────────────────────────────────────────────────────────────┘

MIGRATION PATH (one-shot, idempotent):
  tracked::migrate_legacy_if_needed (Phase 24, tracked.rs:1329)
    │  per-agent loop (already exists)
    │
    ▼
  + Phase 24.1 step: synthesize tracked info.json from
    owlery::read_info_json(perch_id) + perch info.json mtime
    + hostname() at migration time
    →  write the synthesized info.json into agent_worktree_path(id)
    →  Phase 24 commits everything in one migrate commit (subject:
       "migrate: {id} — import legacy flat layout")
    →  short-circuit if tracked info.json already exists (idempotent)
```

### Recommended Project Structure

```
src/
├── common/
│   ├── types.rs         # ADD: TrackedAgentInfo, MachineHistoryEntry,
│   │                    #      ProjectHistoryEntry structs
│   │                    # MODIFY: InfoJson.project_history field type
│   │                    #         (Vec<String> → Vec<ProjectHistoryEntry>)
│   │                    #         under #[serde(default)] for legacy tolerance
│   ├── owlery.rs        # ADD: tracked_agent_info_path(),
│   │                    #      read_tracked_agent_info_or_fallback(),
│   │                    #      bump_tracked_agent_info(),
│   │                    #      append_machine_history primitive,
│   │                    #      shared dedup-or-append-with-branch primitive
│   │                    # MODIFY: append_project_history signature
│   │                    #         (gains branch: &str parameter)
│   ├── git.rs           # MODIFY: hostname() visibility (fn → pub(crate))
│   │                    # OPTIONAL: add pub(crate) head_branch(cwd) helper
│   │                    #          (shell-out to rev-parse --abbrev-ref HEAD)
│   └── tracked.rs       # MODIFY: migrate_legacy_if_needed per-agent loop
│                        #         gains synth-tracked-info-json step
├── live/
│   ├── start.rs         # MODIFY: append_project_history call sites grow
│   │                    #         the new branch param AND a sibling call to
│   │                    #         bump_tracked_agent_info
│   │                    # MODIFY: commit_agent_payload "files" slice grows
│   │                    #         "info.json" when bump returned true
│   ├── context.rs       # MODIFY: same as start.rs — 4 call sites
│   ├── signoff.rs       # MODIFY: same pattern in emit_signoff_trigger
│   └── fork.rs          # MODIFY: fork_files_only commit gets info.json too
├── owl/
│   ├── poll.rs          # MODIFY: append_project_history call site (line 135)
│   ├── echo_commune.rs  # MODIFY: append_session_entry call at line 656 grows
│   │                    #         a sibling bump_tracked_agent_info + commit
│   │                    #         file-list amendment
│   └── doctor.rs        # MODIFY: check_tracked_layout extends per-worktree
│                        #         rows with a parsed-summary sub-line + raw
│                        #         path sub-line (D-14)
└── live/list.rs +       # MODIFY: read_tracked_agent_info_or_fallback
    owl/list.rs          #         consumed for last_started/last_machine/
                         #         last_project columns (D-15)
```

### Pattern 1: Record Shape (TRK-INFO-SCHEMA-01)

**What:** Two element types + one container type, all under `#[serde(default)]` for legacy tolerance and `preserve_order` for locked field ordering.

**When to use:** Defining the on-disk JSON contract for `psyches/tracked/agents/{agent_id}/info.json`.

**Example (sketch — planner picks final struct names):**

```rust
// Source: src/common/types.rs (existing pattern from InfoJson lines 31-62)

/// Phase 24.1 D-05 — element of TrackedAgentInfo.machine_history.
/// Also re-used inside perch InfoJson where the field type is a
/// project-history-flavored sibling (see ProjectHistoryEntry).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineHistoryEntry {
    pub name: String,         // hostname()
    pub first_seen: String,   // RFC-3339 UTC "2026-05-20T08:00:00Z"
    pub last_seen: String,    // RFC-3339 UTC
}

/// Phase 24.1 D-05 — element of project_history (BOTH sides — perch
/// and tracked). Carries the cwd's HEAD branch captured at write time.
/// Dedup key is `name` only; `branch` is overwritten on every bump.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectHistoryEntry {
    pub name: String,
    pub branch: String,        // empty string on no-git/detached/no-repo
    pub first_seen: String,
    pub last_seen: String,
}

/// Phase 24.1 — durable per-agent activity index at
/// `psyches/tracked/agents/{agent_id}/info.json`. Survives perch teardown
/// and machine swaps. Written through the Phase 24 D-13 commit pipeline.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrackedAgentInfo {
    pub agent_id: String,
    pub last_started: String,       // RFC-3339 UTC; bumped on `boot` only (D-06)
    pub last_machine_name: String,  // hostname(); every write (D-07)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_project_name: Option<String>,  // first element of derive_current_repo_names() (D-09)
    #[serde(default)]
    pub machine_history: Vec<MachineHistoryEntry>,
    #[serde(default)]
    pub project_history: Vec<ProjectHistoryEntry>,
}
```

**Field-order rationale:** The serialization order in the struct matters because Cargo.toml has `serde_json` with `preserve_order` (the same feature `sessions.log`'s `SessionEntry` relies on at `tracked.rs:884` to lock field order). Declaration order = wire order, asserted in a byte-comparison test the planner should write.

### Pattern 2: Lifecycle Hook Map (TRK-INFO-LIFECYCLE-01)

| Trigger | Where | Bumps | Code Site |
|---------|-------|-------|-----------|
| `boot` | `start.rs::emit_boot_trigger_after_spawn` (line 446) + `claude.rs::emit_inwrapper_boot` (line 342) | `last_started`, `last_machine_name`, `machine_history` | These run AFTER `append_session_entry(..., "boot")`. Add `bump_tracked_agent_info(id, "boot", &[], "")` right after the boot append. |
| `commune` | `echo_commune.rs:656` (`append_session_entry(self_id, session_uuid, "commune")`) | `last_machine_name`, `machine_history`, `last_project_name`, `project_history` | Add `bump_tracked_agent_info(self_id, "commune", &derive_current_repo_names(), &head_branch())` and amend the upstream commit file-list with `"info.json"`. |
| `signoff` | `signoff.rs::emit_signoff_trigger` (line 48) | same as commune | Same pattern. |
| `pulse` | `claude.rs:295` (`append_session_entry(.., "pulse")`) | NO bump (D-06 — `last_started` is `boot` only; tracked info.json doesn't track pulses) | Do NOT call the new helper here. Pulses are wrapper-internal heartbeats. |
| `context-save` / `amend-signoff` | `context.rs::run_save`, `::run_amend_signoff` (4 sites) | Same as commune (they piggyback on the commune commit funnel) | Pattern unchanged from commune. |
| `migrate` | `tracked::migrate_legacy_if_needed:1329` (per-agent loop) | All fields synthesized from perch info.json mtime | One-shot synth at migration time; no separate commit. |

### Pattern 3: Commit Piggyback Ordering (TRK-INFO-SYNC-01)

Within each existing commit-producing call site, the new ordering is:

```rust
// BEFORE (existing — context.rs::run_save sketch):
//   1. write payload into worktree (live_context.md)
//   2. let subject = compose_commit_subject(...)
//   3. tracked::commit_agent_payload(self_id, &["live_context.md"], &subject)

// AFTER (Phase 24.1):
//   1. write payload into worktree
//   2. let subject = compose_commit_subject(...)
//   3. // NEW: bump tracked info.json before composing the file list
//      let names = owlery::derive_current_repo_names();
//      let branch = git::head_branch_or_empty();  // new helper or inline
//      let info_changed = owlery::bump_tracked_agent_info(
//          self_id, "commune", &names, &branch);
//   4. let mut files = vec!["live_context.md"];
//      if info_changed { files.push("info.json"); }
//   5. tracked::commit_agent_payload(self_id, &files, &subject)
```

**Why bump THEN compose files:** The helper returns whether the file changed on disk; only then is `"info.json"` added to the `git add` set. This avoids an empty `git add info.json` followed by an empty commit (which `git commit -m` would tolerate via `--allow-empty-message` but produce a "nothing to commit" failure path).

**Soft-fail posture:** `bump_tracked_agent_info` is best-effort silent-on-error (matches Phase 32 `append_project_history` and Phase 24 `commit_agent_payload` postures). A bump failure returns `false` (no file change visible) and the commit proceeds without info.json. Payload landing on disk is the guaranteed contract; tracked info.json freshness is a best-effort durability layer.

### Pattern 4: Dedup-or-Append Primitive (TRK-INFO-HISTORY-01)

Build one `serde_json::Value`-level primitive in `owlery.rs` that powers both `machine_history` and `project_history`:

```rust
/// D-08/D-10 primitive. Scans `arr` for an object whose `"name"` field
/// equals `name`. If found: overwrites `"last_seen"` with `now`, AND
/// overwrites `"branch"` with `branch` when Some. Returns true.
/// If not found: appends a new object with `name`, optional `branch`,
/// `first_seen = now`, `last_seen = now`. Returns true.
/// Returns false ONLY when arr is malformed (not an array of objects).
fn append_history_entry(
    arr: &mut Vec<serde_json::Value>,
    name: &str,
    branch: Option<&str>,
    now: &str,
) -> bool {
    for entry in arr.iter_mut() {
        let obj = match entry.as_object_mut() { Some(o) => o, None => continue };
        if obj.get("name").and_then(|v| v.as_str()) == Some(name) {
            obj.insert("last_seen".into(), serde_json::Value::String(now.into()));
            if let Some(b) = branch {
                obj.insert("branch".into(), serde_json::Value::String(b.into()));
            }
            return true;
        }
    }
    let mut new_obj = serde_json::Map::new();
    new_obj.insert("name".into(), serde_json::Value::String(name.into()));
    if let Some(b) = branch {
        new_obj.insert("branch".into(), serde_json::Value::String(b.into()));
    }
    new_obj.insert("first_seen".into(), serde_json::Value::String(now.into()));
    new_obj.insert("last_seen".into(), serde_json::Value::String(now.into()));
    arr.push(serde_json::Value::Object(new_obj));
    true
}
```

**`branch: Option<&str>`** lets one primitive serve both arrays — `machine_history` calls pass `None` (no branch column), `project_history` calls pass `Some(branch)`.

**Mixed-shape tolerance:** When the array contains a legacy `Vec<String>` element (a bare JSON string, not an object), the `as_object_mut()` guard skips it during the lookup pass. The first new write therefore APPENDS a fresh object for that name even though the legacy string for the same name is still in the array. That is acceptable for the rollout window (legacy string entries get rewritten to objects on the next migration pass — see Pattern 6). If the planner wants stricter behavior, add a separate normalization pass that rewrites all legacy strings to objects BEFORE the lookup loop runs (see §Pitfall 5).

### Pattern 5: Tracked Info.json Read/Write (TRK-INFO-SYNC-01)

```rust
/// D-15: canonical path resolver. Mirrors the perch-side `info_file(id)`.
pub fn tracked_agent_info_path(agent_id: &str) -> PathBuf {
    agent_worktree_path(agent_id).join("info.json")
}

/// D-15 listings consumer. Reads the tracked info.json if present;
/// falls back to perch info.json fields when missing. Returns the
/// three durable fields as a tuple (or a small struct — planner picks).
/// Best-effort: returns None on any IO/parse error.
pub fn read_tracked_agent_info_or_fallback(
    perch_id: &str,
) -> Option<TrackedAgentInfoSummary> {
    // 1. Try tracked-side first.
    let tracked_path = tracked_agent_info_path(perch_id);
    if let Ok(content) = std::fs::read_to_string(&tracked_path) {
        if let Ok(info) = serde_json::from_str::<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 fields (read existing helpers).
    //    last_started ← perch InfoJson.started (Phase 23 carryover label;
    //                   note: perch's `started` is local time format —
    //                   format mismatch acknowledged, see §Pitfall 6).
    //    last_machine_name ← unavailable on perch; return git::hostname()
    //    last_project_name ← first element of perch's project_history if
    //                        the field exists (Phase 32 D-02 legacy shape
    //                        OR Phase 24.1 upgraded shape — Value probe).
    // ...
}
```

The fallback path is critical for D-15: very fresh agents (post-Phase-24.1, pre-first-commit) will have a perch info.json but NO tracked info.json yet. Listings should not show empty cells for them.

### Pattern 6: Migration Synth (TRK-INFO-MIGRATE-01)

Extend the existing per-agent loop in `tracked::migrate_legacy_if_needed` (`tracked.rs:1329`):

```rust
// Pseudo-sketch — actual code lives inside the per-agent for-loop in
// migrate_legacy_if_needed (after the .log/.md/.xml renames complete and
// before commit_agent_payload_with_timeout is called):

// PHASE 24.1 MIGRATION SYNTH — runs ONCE per agent at first-boot of v1.8.1.
let tracked_info_path = owlery::tracked_agent_info_path(agent_id);
if !tracked_info_path.exists() {
    // Idempotent: short-circuit if file already exists (e.g. previous
    // partial migration that survived).
    let perch_path = owlery::info_file(agent_id);
    let mtime_iso = perch_info_mtime_iso(&perch_path); // helper: returns
                                                       // chrono RFC-3339 of
                                                       // perch info.json
                                                       // mtime, or now() if
                                                       // perch absent.
    let perch_value: serde_json::Value = std::fs::read_to_string(&perch_path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or(serde_json::Value::Object(Default::default()));
    let legacy_history = perch_value
        .get("project_history")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    // Normalize mixed legacy shape: Vec<String> -> Vec<ProjectHistoryEntry>.
    let project_history: Vec<ProjectHistoryEntry> = legacy_history
        .into_iter()
        .filter_map(|v| {
            if let Some(s) = v.as_str() {
                Some(ProjectHistoryEntry {
                    name: s.to_string(),
                    branch: String::new(),       // no historical signal
                    first_seen: mtime_iso.clone(),
                    last_seen: mtime_iso.clone(),
                })
            } else if let Ok(entry) = serde_json::from_value(v) {
                Some(entry)
            } else {
                None
            }
        })
        .collect();
    let last_project = project_history.first().map(|e| e.name.clone());
    let host = git::hostname();
    let synth = TrackedAgentInfo {
        agent_id: agent_id.to_string(),
        last_started: mtime_iso.clone(),
        last_machine_name: host.clone(),
        last_project_name: last_project,
        machine_history: vec![MachineHistoryEntry {
            name: host,
            first_seen: mtime_iso.clone(),
            last_seen: mtime_iso,
        }],
        project_history,
    };
    let body = serde_json::to_string(&synth).expect("synth serializes");
    let _ = owlery::atomic_write_string(&tracked_info_path, &body);
    // The migrate commit a few lines below will pick this file up via the
    // files slice — planner extends the files Vec accordingly.
}
```

**Two-step idempotency:** Outer Phase 24 migration is already guarded by `MIGRATION_IN_PROGRESS` thread_local + the `seed/` presence check. Phase 24.1 synth is gated by `tracked_info_path.exists()`. Together they tolerate the binary-handoff race (Phase 24 Pitfall 4) — a re-entry simply finds the file already on disk and skips.

### Anti-Patterns to Avoid

- **Hand-rolling RFC-3339 timestamps:** `format!("{}T{:02}:...", year, ...)` is wrong-by-default for timezone handling. Use `chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)`. This is exactly what `tracked::now_iso()` already does (`tracked.rs:942`).
- **Calling `info_changed = true` unconditionally:** The Phase 32 write-amp guard (`owlery.rs:606-608` — `if !changed { return; }`) is why two consecutive identical bumps don't rewrite the file. Phase 24.1 MUST preserve this: the helper returns `false` when nothing actually changed (no new names, no new hostnames, no `boot` trigger), and the caller skips the `git add info.json` step.
- **Typed-struct round-trip during writes:** Drops unknown fields. Use `serde_json::Value` mutate-in-place pattern (Phase 32 precedent at `owlery.rs:575`). Typed deserialization is fine for READS (e.g. doctor surface, listings) because those don't write back.
- **Inline `Command::new("git").arg("rev-parse")` calls scattered across call sites:** Add one helper `git::head_branch_or_empty(cwd: &Path) -> String` (sibling to `git::hostname()`) so the `git -C {cwd} rev-parse --abbrev-ref HEAD` shell-out and its hide-window + 500ms timeout posture live in one place. Otherwise each call site duplicates the hide-window guard from `owlery.rs:491`.
- **Synthesizing migration timestamps as `now()`:** D-13 says perch info.json mtime — not the migration wall-clock — so future operators inspecting tracked info.json can correlate `last_started` with the actual prior-binary's last touch. `now()` is acceptable only when the perch info.json itself is missing (very rare — a freshly-init'd agent with no perch ever).
- **Reading hostname inside the dedup-or-append loop:** `hostname()` shells out (`git.rs:338`). Capture once into a local `let host = git::hostname();` and pass into the bump call. Same for `derive_current_repo_names()` (`owlery.rs:447`) — captures cwd state, has side effects via the `git remote` shell-out.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| ISO-8601 UTC timestamp | Custom `format!` | `chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)` or promote `tracked::now_iso()` to `pub(crate)` in a shared module | Already in tree (`tracked.rs:942`), already locked-format. |
| OS hostname | Custom WMI/uname FFI | `crate::common::git::hostname()` (promote visibility from `fn` to `pub(crate)`) | Already handles env-var-first + shellout fallback + `"unknown"` literal (Phase 23 D-03). [VERIFIED: src/common/git.rs:326] |
| Cross-platform atomic write | Custom `fs::rename` glue | `crate::common::owlery::atomic_write_string` | Already canonical (`owlery.rs:299`); same-volume NTFS/POSIX rename guarantees. |
| Repo name derivation (folder ∪ remote) | Custom shell-out | `crate::common::owlery::derive_current_repo_names()` | Phase 32 D-03; handles SSH form, HTTPS form, `.git` suffix, T-32-01 argv-only. [VERIFIED: src/common/owlery.rs:447] |
| Worktree commit | Custom `git add`/`commit` | `crate::common::tracked::commit_agent_payload` | Phase 24 funnel — handles synthetic identity (Pitfall 3), TrailerScope::Agent (D-08), 500ms timeout (D-13). [VERIFIED: src/common/tracked.rs:1174] |
| Path to perch info.json | Custom `join("info.json")` | `crate::common::owlery::info_file(id)` | Already canonical (`owlery.rs:175`). Add parallel `tracked_agent_info_path(id)` next to it. |

**Key insight:** Every primitive Phase 24.1 needs is already in the codebase as a Phase 23/24/32 carryover. The phase's leverage point is **composition** — new tracked-side helpers stitching together existing primitives — not new infrastructure.

## Runtime State Inventory

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | **Perch info.json `project_history` field type change** — currently `Vec<String>` on every live agent's `owlery/{id}/info.json`. After Phase 24.1 deploy, on the next `$LIVE start` / `$OWL listen`, the upgraded `append_project_history` rewrites the array to `Vec<ProjectHistoryEntry>` via Value round-trip. Mixed-shape arrays (one legacy String + one new Object for the same name) can exist transiently during a binary-handoff window. | Code edit only — the Value round-trip helper handles the migration in place. No separate data-migration task needed. Planner should add a test asserting mixed-shape arrays serialize cleanly. |
| Stored data | **NEW file** `psyches/tracked/agents/{id}/info.json` — created at next migrate or next commune/signoff, whichever fires first. | Data migration: synth from perch info.json + mtime per D-13. Lives inside Phase 24's existing per-agent migration commit. |
| Live service config | None. SPT does not have any external services with cached config that reference this schema. | None. |
| OS-registered state | None. The file lives entirely under `$SPT_HOME/psyches/tracked/`; no OS-level registration (Task Scheduler / pm2 / launchd) embeds it. | None. |
| Secrets/env vars | None. No secrets reference this schema. `$SPT_HOME` env var unchanged. | None. |
| Build artifacts / installed packages | None. No build artifact ships info.json shape (it's a runtime file). The `owl.exe` binary is the only artifact; the new `TrackedAgentInfo` struct is purely additive to the existing crate. | None. Verified: `target/release/owl.exe` rebuilds from source on every deploy via `docs/DEPLOY.ps1`. |

**Cross-machine note:** Phase 35 (gh remote sync, deferred) will roam the agent branch (which carries the tracked info.json) across machines. Phase 24.1 ships single-machine. Cross-machine concurrent writes to the same agent branch are NOT this phase's problem statement (locked: Phase 35).

## Common Pitfalls

### Pitfall 1: `git rev-parse --abbrev-ref HEAD` returns `HEAD` for detached HEAD

**What goes wrong:** Storing the literal string `"HEAD"` as the `branch` field would make every detached-HEAD bump indistinguishable from "we don't know" or "we are on a real branch named HEAD". Worse, `--abbrev-ref` does NOT fail or return empty on detached — it returns the literal `HEAD` token.

**Why it happens:** Documented git behavior; the alternative `--symbolic-full-name HEAD` returns `HEAD` too in the detached case.

**How to avoid:** The new `head_branch_or_empty(cwd)` helper MUST detect this case explicitly. Simplest: invoke `git -C {cwd} symbolic-ref --short -q HEAD` (the `-q` makes it exit non-zero rather than print an error for detached HEAD), then on non-zero exit return `""` per D-05.

**Warning signs:** Test fixture in detached-HEAD state showing `branch: "HEAD"` in serialized JSON.

### Pitfall 2: `chrono::Utc::now().to_rfc3339()` vs `to_rfc3339_opts(Secs, true)`

**What goes wrong:** `to_rfc3339()` includes fractional seconds (`2026-05-20T08:00:00.123456Z`) and may use `+00:00` offset instead of `Z`. The CONTEXT schema (and `sessions.log` line at `tracked.rs:943`) locks the seconds-precision `Z` form.

**Why it happens:** `chrono`'s default RFC-3339 emitter is the long form.

**How to avoid:** Use `to_rfc3339_opts(SecondsFormat::Secs, true)` exactly. The `true` argument forces `Z` suffix instead of `+00:00`. Promote `tracked::now_iso()` to `pub(crate)` in a shared module (e.g. `common/time.rs::now_iso_utc()`) so both `tracked::append_session_entry` AND the new Phase 24.1 helpers use the byte-identical formatter.

**Warning signs:** Test asserting exact wire bytes finds extra `.123456` or `+00:00`. Byte-comparison tests should pin the format like `compose_session_line_locked_field_order` does in `tracked.rs`.

### Pitfall 3: `hostname()` is currently a private fn

**What goes wrong:** `git::hostname()` at `git.rs:326` is `fn hostname()` (no visibility modifier — private to the `git` module). Phase 24.1 needs to call it from `owlery.rs` (and potentially from `tracked.rs::migrate_legacy_if_needed`). Currently it has exactly ONE caller — `git.rs::stamp()` at line 91.

**Why it happens:** It was intended as an internal helper for the Phase 23 Stamp.

**How to avoid:** Promote to `pub(crate) fn hostname()`. This is a one-character change but the CONTEXT lists this function as a reuse target, so the planner must include it as an explicit task.

**Warning signs:** `cargo build` failure: `error[E0603]: function `hostname` is private`.

### Pitfall 4: `bump_tracked_agent_info` running before `ensure_agent_worktree`

**What goes wrong:** The helper writes to `tracked_agent_info_path(agent_id)` which is `agent_worktree_path(agent_id).join("info.json")`. On the very first commune for a fresh agent, the worktree directory does not exist yet (D-16 lazy creation). A naive `atomic_write_string` call would fail with `ENOENT`.

**Why it happens:** `agent_worktree_path` is pure path composition — it does NOT create the directory (`owlery.rs:103` doc comment confirms). The worktree (and its parent dir) materializes inside `tracked::ensure_agent_worktree`.

**How to avoid:** Two options, planner picks:
1. Make the new helper call `ensure_agent_worktree(agent_id)` internally before the write (matches `tracked::append_session_entry` pattern at `tracked.rs:978`).
2. Document that callers must call `ensure_agent_worktree` first; rely on the fact that `commit_agent_payload` already does so (`tracked.rs:1256`). The bump call site then runs BEFORE the commit but AFTER any earlier write of the primary payload (which already triggered `ensure_agent_worktree`).

Option 1 is more robust against future call-site refactors. Option 2 minimizes redundant subprocess work. Recommendation: Option 1, matching `append_session_entry` precedent.

**Warning signs:** Integration test for a fresh agent's first commune fails with ENOENT on info.json write.

### Pitfall 5: Mixed-shape arrays during binary handoff

**What goes wrong:** During the Phase 18.4/18.5 binary handoff window (a v1.10.20-era wrapper still running while the new v1.10.21 wrapper starts), the OLD wrapper may write a legacy `Vec<String>` entry to `project_history` while the NEW wrapper writes a `Vec<ProjectHistoryEntry>` entry for the same repo. The array now contains one bare string AND one object for the same name. Phase 32 D-08 graceful-deserialize tolerates this on READ, but the dedup-or-append logic in Pattern 4 silently skips the legacy string entry during the lookup pass (because `as_object_mut()` returns None for strings), leading to a duplicate logical entry.

**Why it happens:** The Pattern 4 primitive only inspects objects. Bare strings sail through the loop without matching.

**How to avoid:** Add a normalization sweep before the lookup loop. Pseudo-sketch:

```rust
// Run ONCE per write — converts any legacy bare-string entries to
// fully-formed objects with mtime-fallback timestamps. Idempotent
// (already-object entries pass through unchanged).
fn normalize_legacy_strings_to_objects(arr: &mut Vec<Value>, fallback_ts: &str) {
    for entry in arr.iter_mut() {
        if let Some(s) = entry.as_str() {
            let mut obj = serde_json::Map::new();
            obj.insert("name".into(), Value::String(s.to_string()));
            obj.insert("branch".into(), Value::String(String::new()));
            obj.insert("first_seen".into(), Value::String(fallback_ts.into()));
            obj.insert("last_seen".into(), Value::String(fallback_ts.into()));
            *entry = Value::Object(obj);
        }
    }
}
```

Call this at the top of `append_project_history` (and the tracked-side sibling) right after reading the existing array. After one full write cycle, the array is pure-object, and the lookup loop dedupes correctly.

**Warning signs:** Test fixture seeding a mixed-shape array, calling `append_project_history` with one name matching the legacy string, observing the legacy string still in the array AND a new object with the same name (duplicate).

### Pitfall 6: `format_timestamp()` vs `now_iso()` format mismatch

**What goes wrong:** `crate::common::time::format_timestamp()` returns local-time format `"2026-05-20 11:14:31 PST"` — NOT ISO-8601 UTC. The CONTEXT D-05 says element timestamps are "ISO-8601 UTC, format_timestamp()". These two are incompatible. The actual ISO-8601 UTC formatter in the tree is `tracked::now_iso()` at `tracked.rs:942` (a private `fn`).

**Why it happens:** Phase 23 chose local time for the perch info.json `started` field (human-readable for `$OWL list`); Phase 24 chose ISO UTC for sessions.log (machine-parseable for git log scanning). CONTEXT.md unintentionally cross-referenced the wrong helper.

**How to avoid:** Use `now_iso` style (RFC-3339 UTC seconds-precision with `Z`), NOT `format_timestamp`. Recommendation: extract `now_iso()` into `src/common/time.rs::now_iso_utc()` as `pub(crate)`, replace the private duplicate in `tracked.rs`, and have Phase 24.1 helpers use the shared one. This is a small refactor with positive blast radius (one ISO helper for the whole crate). Planner should add a test pinning the byte format `"YYYY-MM-DDTHH:MM:SSZ"`.

**Warning signs:** Test asserting `first_seen.contains('T')` fails because the field starts with `"2026-05-20 "` (space, not `T`).

### Pitfall 7: Write-amp under burst commune cycles

**What goes wrong:** Phase 24's commune commit fires on every `<COMMUNE>` window in the wrapper's jsonl. A burst of communes (say 10 in 30 seconds during heavy turn output) means 10 `bump_tracked_agent_info` invocations. Each one rewrites info.json IF the helper returns `true`. On commune #2+ within the same minute, `last_seen` for the same machine + same project bumps by mere seconds — a real change, so the file rewrites every time. Worse, info.json then gets `git add`'d AND committed every time, polluting the commit log with 10 near-identical "info.json modified" commits.

**Why it happens:** `last_seen` updates every write per D-08/D-10. There is no temporal coalescing.

**How to avoid:** This is **explicitly accepted** per D-08 ("one info.json write per Phase 24 D-13 commit cycle is acceptable write-amp — bounded by the commit cadence, not the pulse cadence"). The CONTEXT acknowledges this. But the planner should note:
1. The commit-log noise is real — each commune commit subject is `commune: {id} echo — …`, not `info: …`, so the info.json change rides as a co-modified file in a meaningful commit. Reviewing `git log -p info.json` shows mostly tiny `last_seen` delta diffs.
2. No mitigation needed in Phase 24.1. Future enhancement (deferred) could add a "skip rewrite if `last_seen` delta < 60s and no other field changed" guard. Not in scope.

**Warning signs:** Operator complaint about info.json filling commit history. Mitigation: documentation, not code.

### Pitfall 8: Doctor sub-line and output width budget

**What goes wrong:** Phase 24 D-17 doctor row is `tracked:{scope}:{name} → {branch} → {state}` (e.g. `tracked:agent:doyle → a-doyle → clean`). The CONTEXT D-14 sub-line adds `{agent_id}: last_started={ts}, last_machine={name}, last_project={name}` plus `{path}`. With a long agent_id + a long machine name + a long project name + a 20-char ISO timestamp, the sub-line can easily exceed 120 chars and wrap ugly in a narrow terminal.

**Why it happens:** No width budget exists in `doctor.rs::run` — output is raw `eprint!`.

**How to avoid:** Either truncate long names with `…` (e.g. cap last_project to 30 chars), or break into multiple sub-lines. Planner's discretion call per CONTEXT. Recommendation: two sub-lines, second one indented two extra spaces, so the visual hierarchy is `worktree row → activity sub-line → path sub-line`. Pattern:

```
  [PASS] tracked:agent:doyle: doyle → a-doyle → clean
         last_started=2026-05-20T08:00:00Z, last_machine=desktop, last_project=claude_skill_owl
         path=%LOCALAPPDATA%\spt\psyches\tracked\agents\doyle\info.json
```

**Warning signs:** Operator on 80-col terminal sees wrap-mangled output. Tests should pin sub-line formatting via snapshot fixture (extend `check_tracked_layout_one_agent_clean` test at `doctor.rs:628`).

## Code Examples

Verified patterns lifted from the existing codebase. All file:line references are accurate as of 2026-05-20.

### Example 1: Value Round-Trip Mutate-in-Place (Phase 32 precedent — extend in place)

```rust
// Source: src/common/owlery.rs:566-616 (existing append_project_history)
// Phase 24.1 modifies this in place: signature gains `branch: &str`,
// inner loop calls the new append_history_entry primitive, return type
// gains `bool` so callers know whether to add the file to the git add set.
pub fn append_project_history(perch_id: &str, names: &[String], branch: &str) -> bool {
    if names.is_empty() {
        return false;
    }
    let info_path = info_file(perch_id);
    let content = match std::fs::read_to_string(&info_path) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let mut info: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return false,
    };
    let obj = match info.as_object_mut() {
        Some(o) => o,
        None => return false,
    };
    let now = crate::common::time::now_iso_utc();  // NEW shared helper
    let mut history: Vec<serde_json::Value> = obj
        .get("project_history")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    normalize_legacy_strings_to_objects(&mut history, &now);  // Pitfall 5
    let mut changed = false;
    for n in names {
        if append_history_entry(&mut history, n, Some(branch), &now) {
            changed = true;
        }
    }
    if !changed {
        return false;
    }
    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);  // perch is NOT atomic
                                                      // currently — Phase 32
                                                      // didn't lift it to
                                                      // atomic_write_string;
                                                      // planner may upgrade
                                                      // for consistency
    }
    true
}
```

### Example 2: Tracked-Side Sibling Helper (NEW)

```rust
// Source: src/common/owlery.rs (NEW — placed adjacent to append_project_history)
//
// Mirrors append_project_history but writes into the agent worktree's
// info.json. Atomic write via the canonical helper. Returns true iff
// the file content actually changed (write-amp guard).

pub fn bump_tracked_agent_info(
    agent_id: &str,
    trigger: &str,                // "boot" | "commune" | "signoff" | "migrate"
    project_names: &[String],     // from derive_current_repo_names()
    branch: &str,                 // from git::head_branch_or_empty()
) -> bool {
    let host = crate::common::git::hostname();
    let now = crate::common::time::now_iso_utc();
    let info_path = tracked_agent_info_path(agent_id);

    // Materialize the parent worktree dir if missing (Pitfall 4).
    // ensure_agent_worktree is the canonical primitive; soft-fail on
    // git-missing (D-04) leaves the dir as a plain folder without .git.
    let _ = crate::common::tracked::ensure_agent_worktree(agent_id);

    // Read-or-synthesize. Empty Value if file missing.
    let mut info_value: serde_json::Value = std::fs::read_to_string(&info_path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| serde_json::json!({
            "agent_id": agent_id,
            "last_started": "",
            "last_machine_name": "",
            "machine_history": [],
            "project_history": []
        }));

    let obj = match info_value.as_object_mut() {
        Some(o) => o,
        None => return false,
    };
    let mut changed = false;

    // D-06: last_started bumps on `boot` only.
    if trigger == "boot" || trigger == "migrate" {
        let prior = obj.get("last_started").and_then(|v| v.as_str()).unwrap_or("");
        if prior != now {
            obj.insert("last_started".into(), serde_json::Value::String(now.clone()));
            changed = true;
        }
    }

    // D-07: last_machine_name bumps on every write.
    let prior_host = obj.get("last_machine_name").and_then(|v| v.as_str()).unwrap_or("");
    if prior_host != host {
        obj.insert("last_machine_name".into(), serde_json::Value::String(host.clone()));
        changed = true;
    }

    // D-08: machine_history (no branch field).
    let mut mh: Vec<serde_json::Value> = obj
        .get("machine_history")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    if append_history_entry(&mut mh, &host, None, &now) {
        obj.insert("machine_history".into(), serde_json::Value::Array(mh));
        changed = true;
    }

    // D-09 + D-10: project_history (with branch). Only on commune/signoff/migrate.
    if matches!(trigger, "commune" | "signoff" | "migrate") && !project_names.is_empty() {
        // D-09: last_project_name = first name.
        let first = &project_names[0];
        let prior_proj = obj.get("last_project_name").and_then(|v| v.as_str()).unwrap_or("");
        if prior_proj != first {
            obj.insert("last_project_name".into(), serde_json::Value::String(first.clone()));
            changed = true;
        }
        // D-10: append/bump each name.
        let mut ph: Vec<serde_json::Value> = obj
            .get("project_history")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();
        normalize_legacy_strings_to_objects(&mut ph, &now);
        for n in project_names {
            if append_history_entry(&mut ph, n, Some(branch), &now) {
                changed = true;
            }
        }
        obj.insert("project_history".into(), serde_json::Value::Array(ph));
    }

    if !changed {
        return false;
    }

    // D-12: atomic write.
    let body = match serde_json::to_string(&info_value) {
        Ok(s) => s,
        Err(_) => return false,
    };
    atomic_write_string(&info_path, &body).is_ok()
}
```

### Example 3: Call-Site Integration (commune funnel)

```rust
// Source: src/owl/echo_commune.rs (modify around line 656).
// The Phase 24 commune append (append_session_entry) is unchanged.
// The new bump + commit-file amendment runs in parallel.
//
// NOTE: echo_commune does NOT directly invoke commit_agent_payload — it
// emits the commune trigger entry and the SUBSEQUENT context.rs flow
// (run_save / run_amend_signoff) is where the commit fires. The bump
// must happen at the SAME point as the commit, so the change goes in
// context.rs, not echo_commune.rs. See context.rs:373-381 for the
// canonical pattern.
//
// In context.rs::run_save (line 363-381 in current tree):
let names = owlery::derive_current_repo_names();
let branch = crate::common::git::head_branch_or_empty();  // NEW helper
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);
}
```

### Example 4: Migration Synth Hook (one-shot, idempotent)

```rust
// Source: src/common/tracked.rs::migrate_legacy_if_needed (around line 1329).
// Inside the per-agent loop, AFTER the .log/.md/.xml renames and BEFORE
// commit_agent_payload_with_timeout is called:

let tracked_info_path = owlery::tracked_agent_info_path(agent_id);
if !tracked_info_path.exists() {
    // Synthesize from perch info.json + mtime fallback. Soft-fail on any error.
    let _ = synth_tracked_info_at_migration(agent_id);
}

// Then extend the migrate commit's files slice to include "info.json"
// when it was synthesized:
let mut migrate_files: Vec<&str> = collect_migrated_files(...);  // existing
if tracked_info_path.exists() {
    migrate_files.push("info.json");
}
commit_agent_payload_with_timeout(
    agent_id, &migrate_files, &migrate_subject,
    Duration::from_millis(MIGRATE_TIMEOUT_MS),
)?;
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Perch `project_history: Vec<String>` (Phase 32 D-02/D-08) | Perch + Tracked `project_history: Vec<ProjectHistoryEntry>` with `{name, branch, first_seen, last_seen}` | Phase 24.1 | Locked in CONTEXT D-05. Phase 32 helper `append_project_history` upgraded in place. |
| Single-machine activity tracked only in perch info.json (lost on perch teardown) | Durable tracked info.json carrying `last_started`/`last_machine_name`/`last_project_name` across perch lifecycles, machine swaps (with Phase 35 also across machines) | Phase 24.1 | Survives perch teardown — the perch info.json is transient; tracked info.json is the durable snapshot. |
| ISO timestamp formatter scattered (one private `now_iso()` in `tracked.rs`, no shared crate helper) | Promote to `pub(crate)` in `common/time.rs` so Phase 24.1 helpers + Phase 24 `sessions.log` share the byte-identical formatter | Phase 24.1 (proposed) | Reduces duplication and prevents drift between the two timestamp-emitting subsystems. |

**Deprecated/outdated:**
- ROADMAP schema draft's `root` field on `project_history` entries (line 475) — explicitly dropped per CONTEXT D-02. Planner should NOT include `root`.
- Local-time `format_timestamp()` for the new history element timestamps — wrong format despite CONTEXT D-05 citing it (see Pitfall 6). Use ISO-UTC `now_iso` form instead.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)` produces `"2026-05-20T08:00:00Z"` (Z suffix, no fractional seconds) | §Pitfall 2, §Standard Stack | LOW — verified by reading existing `tracked.rs:943` which uses this exact call and the existing `compose_session_line_locked_field_order` test which pins its output bytes. Documented behavior of chrono 0.4. |
| A2 | `git rev-parse --abbrev-ref HEAD` returns literal `"HEAD"` in detached state | §Pitfall 1 | LOW — documented git behavior, well-known. Mitigation via `symbolic-ref --short -q` is the standard idiom across git tooling. |
| A3 | Promoting `git::hostname()` from `fn` to `pub(crate)` is the right visibility level | §Pitfall 3 | LOW — `owlery::derive_current_repo_names` (the other identity source the CONTEXT pairs with hostname) is already `pub`. `pub(crate)` matches the actual cross-module usage scope. |
| A4 | Phase 24's `commit_agent_payload` tolerates an "info.json" file in the `files` slice without special treatment | §Code Examples 3 | LOW — `commit_payload` (`tracked.rs:1119-1163`) does `git add` on each file by relative-to-worktree path then `git commit`. No file-name filtering. Verified by reading the implementation. |
| A5 | Mixed-shape arrays surviving in `project_history` during binary handoff are real (not just theoretical) | §Pitfall 5 | MEDIUM — Phase 18.4/18.5 handoff is the only known window. The Phase 24 verification report (`24-VERIFICATION.md` Pitfall 4 mitigation) confirms this race exists for legacy flat files; the same window applies to project_history shape transitions. Normalization sweep is the standard mitigation. |
| A6 | The Phase 24 migrate commit can carry an additional file (`info.json`) without breaking the existing `migrate_legacy_orphan_recovery` test | §Code Examples 4 | LOW — the test asserts SC4 file moves; it does not pin the exact files slice. Planner should verify by running the test after the change. |
| A7 | `last_project_name` as `Option<String>` is the right Rust type (rather than empty `String`) | §Pattern 1, CONTEXT Claude's Discretion | LOW — CONTEXT recommends Option; matches the precedent set by `InfoJson.cwd` (`types.rs:51`) for "value may not yet exist" semantics. Serialized as absent JSON key via `skip_serializing_if = "Option::is_none"`. |

**If this table is concerning:** A5 is the only MEDIUM. Planner should write at least one integration test that simulates a mixed-shape 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.

## Open Questions (RESOLVED)

> All four open questions were resolved during planning (Plans 02-05) and are
> reproduced here with explicit `RESOLVED:` markers for audit trail. The
> `Recommendation:` line remains for provenance; `RESOLVED:` records the
> locked answer that landed in the plan bodies.

1. **Does the planner upgrade perch info.json writes to `atomic_write_string` for symmetry with tracked side (D-12)?**
   - What we know: D-12 says atomic for tracked side. Current `append_project_history` at `owlery.rs:614` uses non-atomic `std::fs::write`.
   - What's unclear: Whether CONTEXT D-12 implies symmetry (atomic on BOTH sides) or only on tracked side.
   - Recommendation: Upgrade perch write to atomic too. Low cost; consistency win; closes the partial-write window for perch info.json that already exists in the current tree as a pre-existing latent bug.
   - **RESOLVED:** YES — perch write upgraded to `atomic_write_string` for symmetry. Locked in Plan 02 Task 3 action: the upgraded `append_project_history` body uses `atomic_write_string(&info_path, &body).is_ok()` (replacing the prior non-atomic `std::fs::write`). Closes the latent partial-write window on the perch side.

2. **Should `head_branch_or_empty(cwd)` live in `git.rs` (as sibling to `hostname()`) or in `owlery.rs` (as sibling to `derive_current_repo_names()`)?**
   - What we know: Both modules host shell-out helpers. `derive_current_repo_names` already shells out to `git remote get-url origin` and lives in `owlery.rs`.
   - What's unclear: Stylistic call by the planner.
   - Recommendation: `git.rs` — the helper is a pure git operation, not a repo-name derivation. Matches `hostname()`'s placement.
   - **RESOLVED:** `git.rs` — locked in Plan 01 Task 2. The new `pub(crate) fn head_branch_or_empty(cwd: &Path) -> String` lives in `src/common/git.rs` as a sibling to the promoted `pub(crate) fn hostname()`. Pure git CLI operation; no repo-name derivation semantics.

3. **Does `bump_tracked_agent_info` for `trigger = "boot"` need access to repo names + branch, or are those `commune`/`signoff` only?**
   - What we know: D-06 says `last_started` updates on boot. D-09 says `last_project_name` updates on commune/signoff. So boot does NOT touch `last_project_name` or `project_history`.
   - What's unclear: Whether boot still bumps `machine_history` (D-08 says "every tracked write" which includes boot).
   - Recommendation (locked by reading D-07 + D-08): Boot DOES bump `last_machine_name` AND `machine_history` (because boot IS a tracked write). Boot does NOT touch project fields. The helper sketched in Code Examples 2 already handles this via the `if matches!(trigger, "commune" | "signoff" | "migrate")` guard. Verify with the planner.
   - **RESOLVED:** Boot bumps machine-only, NOT project. Locked in Plan 02 Task 2 (helper sequencing D-07 + D-08 unconditional; D-09 + D-10 guarded by `matches!(trigger, "commune" | "signoff" | "migrate")`) AND Plan 03 Task 2 (boot call sites pass empty `&[]` for project_names and `""` for branch). Boot signature: `bump_tracked_agent_info(id, "boot", &[], "")`. The bump funnel for `relocate_previous_log` daemon.log commit at `src/live/start.rs:72` is owned by Plan 03 Task 2 under boot semantics (machine-only bump, no project fields) per the same rule.

4. **Does `$LIVE psyche-download` need any code change in Phase 24.1, or is the `tracked_agent_info_path(id)` helper sufficient?**
   - What we know: D-15 says "expose tracked info.json path to consumers" and "Psyche-download payload shape itself is NOT changed in Phase 24.1 — that is Phase 25's territory."
   - What's unclear: Whether the path resolver needs to be wired into the current psyche-download command, or just exposed as a helper for Phase 25 to call.
   - Recommendation: Expose the helper only. Phase 25 will consume it. Phase 24.1's psyche-download surface change is the listings change (last_started / last_machine / last_project columns).
   - **RESOLVED:** Helper-only exposure; no psyche-download payload reshape in Phase 24.1. `owlery::tracked_agent_info_path(id)` and `owlery::read_tracked_agent_info_or_fallback(id)` are exposed for Phase 25 to consume. No call-site wiring in psyche-download body for this phase. Listings (`$LIVE list`, `$OWL list`) MAY consume `read_tracked_agent_info_or_fallback` as a follow-on (Plan 05 Task 2 smoke verifies the helpers exist; full listings wire-up is Phase 25 territory per CONTEXT D-15).

## Environment Availability

> This phase is purely code/config changes to a Rust crate. No external runtime tools or services need to be installed.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `git` CLI | head-branch helper + Phase 24 commit funnel | ✓ (assumed — Phase 24 D-02 fallback already handles absence) | — | D-02: degrade silently, info.json still written as raw file, just uncommitted. |
| Rust toolchain | Build the crate | ✓ | (project uses stable, edition 2021 per Cargo.toml:4) | — |
| `serde`, `serde_json` (with `preserve_order`), `chrono` (with `clock`) | All schema + timestamp work | ✓ | All in `Cargo.toml` | — |
| `tempfile` (dev-dep) | New tests in the `common/owlery.rs` cluster | ✓ | `3` in dev-deps | — |

**Missing dependencies with no fallback:** None.
**Missing dependencies with fallback:** `git` — fallback already designed via Phase 24 D-02 and reused unchanged in Phase 24.1 D-04.

## Project Constraints (from CLAUDE.md)

| Constraint | How Phase 24.1 Honors |
|------------|----------------------|
| **Platform: Windows native + Unix** | All new helpers use existing cross-platform primitives (`atomic_write_string`, `hostname` already handles both, no FFI). |
| **Portability: copy-files-and-go install, no runtime deps** | Zero new deps added. All helpers are pure Rust + existing crates. |
| **Backward compat: existing skill commands must keep working** | No SKILL.md changes. No new commands. All existing tests must continue to pass; the only behavioral delta is `info.json` files showing up as co-modified inside existing commune/signoff commits. |
| **Output: stderr=status / stdout=body** | Doctor sub-line uses existing `eprint!` (stderr) — matches Phase 24 D-17 row pattern at `doctor.rs:41`. |
| **snake_case fns, PascalCase types** | New fns `bump_tracked_agent_info`, `tracked_agent_info_path`, `append_history_entry`, etc. all snake_case. New types `TrackedAgentInfo`, `MachineHistoryEntry`, `ProjectHistoryEntry` all PascalCase. |
| **`pub(crate)` for cross-module** | `hostname()` promotion from `fn` to `pub(crate)`; `now_iso_utc` shared helper as `pub(crate)`. Public-API helpers (`tracked_agent_info_path`, `read_tracked_agent_info_or_fallback`) are `pub` because they cross the owlery → listings module boundary. |
| **Platform splits via `#[cfg(unix)]` / `#[cfg(windows)]`** | Not needed — all helpers route through existing platform-agnostic primitives. |
| **GSD Workflow Enforcement** | This RESEARCH.md goes through `/gsd:plan-phase` next; no direct repo edits. |

## Sources

### Primary (HIGH confidence — codebase truth)

- `src/common/types.rs:1-90` — current `InfoJson` shape; `project_history: Vec<String>` field at line 60. [VERIFIED via direct Read]
- `src/common/owlery.rs:447-616` — `derive_current_repo_names`, `append_project_history`, the Value round-trip pattern. [VERIFIED via direct Read]
- `src/common/owlery.rs:1182-1316` — Phase 32 test cluster (the extension target). [VERIFIED via direct Read]
- `src/common/owlery.rs:93-122` — Phase 24 tracked-path helpers (`tracked_root`, `seed_path`, `agent_worktree_path`, `agent_branch`). [VERIFIED via direct Read]
- `src/common/owlery.rs:299-306` — `atomic_write_string` (the D-12 primitive). [VERIFIED via direct Read]
- `src/common/git.rs:326-355` — `hostname()` (currently private fn). [VERIFIED via direct Read + Grep]
- `src/common/tracked.rs:1119-1258` — Phase 24 commit funnel (`commit_payload`, `commit_agent_payload`, `commit_agent_payload_with_timeout`). [VERIFIED via direct Read]
- `src/common/tracked.rs:880-1010` — sessions.log helpers including private `now_iso()` at line 942 and `append_session_entry`. [VERIFIED via direct Read]
- `src/common/tracked.rs:1212-1242` + `:1260-` — seal-and-rotate and migration entry points. [VERIFIED via direct Read]
- `src/live/start.rs:255-282`, `:446`, `:676` — boot-trigger emit sites + reconnect-branch `append_project_history` call site. [VERIFIED via direct Read]
- `src/live/context.rs:315-388` — `run_save` commit pipeline (canonical pattern for the new info.json file-list amendment). [VERIFIED via direct Read]
- `src/live/signoff.rs:42-104` — `emit_signoff_trigger` + `run` (the signoff bump site). [VERIFIED via direct Read]
- `src/owl/poll.rs:110-136` — listen-perch `append_project_history` call site. [VERIFIED via direct Read]
- `src/owl/echo_commune.rs:637-661` — commune trigger emit site. [VERIFIED via direct Read]
- `src/owl/doctor.rs:1-507` — Phase 24 D-17 doctor surface (the D-14 sub-line extension site). [VERIFIED via direct Read]
- `Cargo.toml:1-26` — dependency manifest confirming `serde_json` `preserve_order`, `chrono` `clock`, `tempfile` `3`. [VERIFIED via direct Read]
- `.planning/phases/24-tracked-dir-forked-repo-layout-agents-projects-branches-sess/24-CONTEXT.md` — Phase 24 substrate decisions. [VERIFIED via direct Read]
- `.planning/phases/24-…/24-VERIFICATION.md` — Phase 24 ship confirmation including the line numbers cited throughout this research. [VERIFIED via direct Read]
- `.planning/phases/32-list-overhaul-skill-hint-audit/32-CONTEXT.md` — Phase 32 D-08 graceful-deserialize precedent. [VERIFIED via direct Read]
- `.planning/phases/24.1-…/24.1-CONTEXT.md` — locked decisions D-01..D-15. [VERIFIED via direct Read]
- `.planning/phases/24.1-…/24.1-DISCUSSION-LOG.md` — rationale for each lock + post-lock branch amendment. [VERIFIED via direct Read]
- `.planning/ROADMAP.md:456-497` — Phase 24.1 entry with 10 success criteria + ROADMAP schema draft (note `root` field dropped). [VERIFIED via direct Read]
- `.planning/config.json` — `nyquist_validation: false`, `commit_docs: true`. [VERIFIED via direct Read]

### Secondary (MEDIUM confidence — external knowledge cross-referenced with code)

- `chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)` produces `Z`-suffix RFC-3339 — confirmed by inspecting `tracked.rs:942` which uses this exact call and the `compose_session_line_locked_field_order` test which pins the resulting bytes.
- `git rev-parse --abbrev-ref HEAD` detached-HEAD returns `HEAD` — documented git behavior; cross-referenced against the existing `git::run_git_checked` shell-out posture in `tracked.rs`. The `symbolic-ref --short -q HEAD` mitigation is the standard git tooling idiom.

### Tertiary (LOW confidence — none)

No claims in this research rely on unverified web sources. All findings are codebase-verified.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all crates already in tree and version-pinned in `Cargo.toml`.
- Architecture patterns: HIGH — all patterns are extensions of Phase 23/24/32 precedents with file:line citations.
- Pitfalls: HIGH — derived from direct code-reading; the format-mismatch pitfall (P6) caught a real CONTEXT.md inconsistency.
- Migration: HIGH — extends the well-tested Phase 24 `migrate_legacy_if_needed` per-agent loop.
- Doctor surface: MEDIUM — formatting choice is Claude's discretion per CONTEXT, recommendation given but final layout up to the planner.

**Research date:** 2026-05-20
**Valid until:** 2026-06-19 (30 days — Phase 24 substrate is stable, no fast-moving deps).
