# threads

id: 019e5259-3779-7c62-aaa3-a6a9029602be
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\claude_skill_owl
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: 30951b81f0f6aef5f0d5d00574644ee029fef583
git_branch: main
git_origin_url: https://github.com/SaberMage/claude_skill_owl.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\22\rollout-2026-05-22T18-00-39-019e5259-3779-7c62-aaa3-a6a9029602be.jsonl

title:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Plans to Review

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  must_haves:
    truths:
      - "When a live agent has `last_project_name` set in tracked-agents info.json, `build_current_context_blocks` resolves the project to that value (NOT `tracked`, NOT psyche_dir basename)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` returned by `derive_current_repo_names()` D-06 fallback is rejected as a false positive and the project-block is omitted instead."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new helper used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via the `build_current_context_blocks` helper (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-first project resolution + `tracked` rejection"
        contains: "last_project_name"
      - path: "tests/native_owl.rs"
        provides: "Regression tests for B2 fix: info.json wins; tracked rejected; missing info.json falls through"
        contains: "build_current_context_blocks"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::tracked_agent_info_path + TrackedAgentInfo.last_project_name"
        via: "typed read of tracked-agents info.json"
        pattern: "tracked_agent_info_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md`. The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) resolves the project name via `derive_current_repo_names()`, which reads `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and route_two_slice writes to `projects/tracked/<self_id>.md` (or — empirically — drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  The same fire pass at line 869 already correctly resolves via `resolve_self_project_stamp(self_id)`, which is supposed to read Self's project from info.json. But that helper reads the PERCH info.json `project` field (line 132–139) — a field that is never populated — and falls back to cwd-stamp. Phase 24.1 D-09 lifted the canonical `last_project_name` field to the TRACKED-AGENTS info.json (`tracked_agent_info_path(self_id)` → `agents/<id>/info.json`, struct `TrackedAgentInfo.last_project_name`). The TODO at echo_commune.rs:108 explicitly flags this: "use `last_project_name` from tracked agents/info.json once available; current v1.8 fallback = psyche_dir basename via stamp()."

  This plan:
  1. Upgrades the project-name resolver to prefer tracked-agents info.json `last_project_name` over the unused perch info.json `project` field.
  2. Wires `build_current_context_blocks` through the corrected resolver (currently it bypasses the helper entirely and goes straight to `derive_current_repo_names()`).
  3. Adds a `"tracked"` rejection guard so the D-06 false positive can never leak through even when info.json resolution fails.
  4. Audits the two other callers of `build_current_context_blocks` (signoff prompt-build path; any pulse/init context assembly site) to confirm they pick up the fix transparently.

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name, so no downstream writer is ever exercised. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/owl/echo_commune.rs` — corrected `build_current_context_blocks` + supporting helper.
  - New unit tests in `tests/native_owl.rs` (or sibling) covering info.json hit / info.json miss / `tracked` rejection.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers the executor needs. Extracted from src/common/owlery.rs + src/common/types.rs. -->
  <!-- The fix wires build_current_context_blocks to consume TrackedAgentInfo.last_project_name. -->

  From src/common/types.rs (line 72-90):
  ```rust
  /// in this exact sequence. `last_project_name` is `Option<String>` so
  pub struct TrackedAgentInfo {
      pub agent_id: String,
      pub last_started: Option<String>,
      pub last_machine_name: Option<String>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub last_project_name: Option<String>,
      pub machine_history: Vec<MachineHistoryEntry>,
      pub project_history: Vec<ProjectHistoryEntry>,
  }
  ```

  From src/common/owlery.rs (line 901, 1054-1075):
  ```rust
  pub fn tracked_agent_info_path(agent_id: &str) -> PathBuf;
  // Pure path resolver: $SPT_HOME/psyches/tracked/agents/<agent_id>/info.json
  // Does NOT create the directory; safe to call on missing-agent paths.

  pub struct TrackedAgentInfoSummary {
      pub last_project_name: Option<String>,
      // ... other Phase 24.1 fields
  }
  // Lightweight read-only projection.
  ```

  From src/common/owlery.rs (line 1253):
  ```rust
  pub fn read_tracked_agent_info_summary(perch_id: &str) -> Option<TrackedAgentInfoSummary>;
  // Tracked-first read; returns None if tracked info.json missing/malformed.
  ```

  From src/common/owlery.rs (`project_worktree_path` — used at echo_commune.rs:415):
  ```rust
  pub fn project_worktree_path(project_name: &str) -> PathBuf;
  // $SPT_HOME/psyches/tracked/projects/<project_name>/
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written; the (a) branch always returns None; falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — totally bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today, but Phase 24.1 means it CAN get the right name if the helper is upgraded.
  </interfaces>

  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Upgrade project-name resolution to read tracked-agents info.json last_project_name</name>
    <files>src/owl/echo_commune.rs, tests/native_owl.rs</files>
    <behavior>
      - When `tracked_agent_info_path(self_id)` exists AND its TrackedAgentInfo has `last_project_name = Some("claude_skill_owl")`, the resolver returns `"claude_skill_owl"`.
      - When `tracked_agent_info_path(self_id)` exists but `last_project_name` is None / empty / missing, the resolver returns None (does NOT fall back to cwd-stamp at the resolver level — caller decides).
      - When `tracked_agent_info_path(self_id)` does not exist (path missing), the resolver returns None.
      - When `last_project_name = Some("tracked")` (defensive — should not happen but guard regardless), the resolver returns None (reject the D-06 false positive at the source).
      - The existing `resolve_self_project_stamp(self_id)` (line 110) is refactored to consult the new helper FIRST, then fall back to the legacy perch info.json `project` read (kept for back-compat with any populated field), then fall back to `crate::common::git::stamp()` cwd basename.
      - The TODO comment at line 108 is removed (it is being fulfilled here).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 80-140 in full.

      Add a new helper after `read_self_project_from_info` (around line 140):
      ```
      fn read_tracked_last_project_name(self_id: &str) -> Option<String>
      ```
      that calls `crate::common::owlery::read_tracked_agent_info_summary(self_id)` and returns `summary.last_project_name` after filtering out empty strings AND the literal `"tracked"` (case-sensitive — `tracked` is the known D-06 false positive; any agent that genuinely has a project named `tracked` is the user's problem). Document the `"tracked"` rejection with an inline comment citing `.planning/debug/todlando-project-context-not-persisted.md` Defect B2 and the D-06 false positive origin.

      Refactor `resolve_self_project_stamp` (line 110-127) to consult the new helper FIRST:
      1. Try `read_tracked_last_project_name(self_id)` — if Some, use that name; build stamp via `crate::common::git::stamp()` and overwrite `s.project = name`.
      2. Else try `read_self_project_from_info(self_id)` (existing legacy perch field — kept for forward compat; if a future phase populates it, it still works).
      3. Else return plain `crate::common::git::stamp()` (existing fallback; will be `tracked` from psyche_dir).

      Remove the TODO comment at lines 108-109 (it is being fulfilled).

      DO NOT modify `build_current_context_blocks` in this task — that is Task 2. Keep this task scoped to the resolver upgrade so the existing outbound-stamp consumer at line 869 picks up the fix transparently and we can test the helper in isolation.

      Add unit tests in `tests/native_owl.rs` (or create a sibling `tests/echo_commune_resolver.rs` if scope-isolation is cleaner — choose based on existing test-file conventions; if `tests/native_owl.rs` has resolver-shaped tests already, extend it):
      - `read_tracked_last_project_name_returns_name_when_info_json_has_last_project_name`
      - `read_tracked_last_project_name_returns_none_when_info_json_missing`
      - `read_tracked_last_project_name_returns_none_when_last_project_name_field_absent`
      - `read_tracked_last_project_name_rejects_literal_tracked_false_positive`
      - `resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field`

      Note: `read_tracked_last_project_name` is private to `echo_commune.rs`; tests use the `#[cfg(test)] mod tests` block at the bottom of the file (already exists per existing tests at line 1736+). Place new helper tests there alongside the existing escape-helper tests. The `tests/native_owl.rs` integration-style tests are for end-to-end stamping behavior — keep those for resolver-via-stamp assertions if needed.
    </action>
    <verify>
      <automated>cargo test --release --lib resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field read_tracked_last_project_name 2>&1 | tail -30</automated>
    </verify>
    <done>
      - New helper `read_tracked_last_project_name` exists and is unit-tested.
      - `resolve_self_project_stamp` consults the new helper FIRST.
      - TODO at line 108-109 removed.
      - 5 unit tests pass.
      - `cargo build --release` succeeds (binary rebuild required for downstream task; this build is the witness).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks to use the corrected resolver + reject tracked false positive</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked with cwd=psyche_dir, where tracked-agents info.json for `todlando` has `last_project_name = Some("claude_skill_owl")`, produces a prompt containing `CURRENT_PROJECT_CONTEXT:` followed by the content of `projects/claude_skill_owl/todlando.md` (or the first-commune literal if the file is absent).
      - The same call when info.json has `last_project_name = None` AND `derive_current_repo_names()` returns `["tracked"]` produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block (the `"tracked"` D-06 fallback is rejected; haiku correctly sees "no tracked project" and emits only `<live-context>`).
      - The same call when info.json is missing AND `derive_current_repo_names()` returns `["my_real_repo"]` produces a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `my_real_repo` (the cwd fallback path still works for non-haiku callers — e.g. signoff prompt-build invoked from a real project cwd).
      - Outbound EVENT stamping at line 869 (resolve_self_project_stamp) and the new inner-block resolution at line 411 BOTH read the same project name in the same fire pass. No asymmetry.
    </behavior>
    <action>
      Modify `src/owl/echo_commune.rs::build_current_context_blocks` (lines 399-423).

      Replace the `derive_current_repo_names().first()` call at line 411 with a layered resolver:

      Step 1 — Try tracked-agents info.json first via the new helper from Task 1 (`read_tracked_last_project_name(self_id)`). If Some(name), use that.

      Step 2 — Else fall back to `owlery::derive_current_repo_names().first()` (the existing cwd-derived path), BUT add a guard: if the returned name is the literal `"tracked"`, treat it as None (reject the D-06 false positive per Defect B2). This guard is belt-and-braces — Task 1 already filters in the helper, but this second guard ensures the cwd-only path also cannot leak `"tracked"`.

      Step 3 — If no name resolved, omit the project block entirely (existing behavior preserved — emits `CURRENT_LIVE_CONTEXT` only, haiku correctly emits only `<live-context>` per psyche.md rule 2).

      Preserve the existing first-commune-literal fallback for the file-read step (when project name resolves but the `projects/<name>/<self_id>.md` file is absent → emit block with `(none — first commune in project)`).

      Update the doc comment block on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (tracked-info → cwd → omit).
      - Keep the existing Plan 04 Task 4 DRY note about the signoff prompt builder reusing this helper.
      - Note that outbound EVENT stamping at run_echo_commune line 869 and inner-block resolution here MUST stay in sync; both consume `read_tracked_last_project_name` first.

      Add unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_tracked_info_last_project_name`
      - `build_current_context_blocks_rejects_tracked_d06_false_positive`
      - `build_current_context_blocks_omits_block_when_no_project_resolved`
      - `build_current_context_blocks_uses_first_commune_literal_when_project_file_missing`

      Tests will need to set up a temp SPT_HOME with a tracked agent info.json. Existing tests in this file already do this pattern (search for `tracked_agent_info_path` usage in test code); follow the established harness.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `read_tracked_last_project_name` first.
      - `"tracked"` literal is explicitly rejected at the cwd-fallback step.
      - 4 unit tests pass.
      - Doc comment updated with Defect B2 attribution + sync invariant with line 869.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Audit shared callers + rebuild binary + manual smoke-test stamping symmetry</name>
    <files>src/live/signoff.rs, src/owl/echo_commune.rs</files>
    <behavior>
      - `crate::live::signoff::compose_init_signoff_payload` (the Plan 04 DRY reuse site at echo_commune.rs:397) calls `build_current_context_blocks` and therefore picks up the fix transparently — no code change needed there, but confirm via grep + trace.
      - No other caller of `build_current_context_blocks` exists OR each additional caller is documented and verified to handle the new "may omit project block" behavior correctly.
      - No other caller of `derive_current_repo_names()` in the haiku/signoff prompt-build path relies on the `"tracked"` fallback as intended behavior. (Confirmed by grep + reading each call site; document findings inline in the SUMMARY.)
      - After `cargo build --release` + binary handoff, a manual smoke trace shows: outbound EVENT envelope `project=` attr (from resolve_self_project_stamp at line 869) matches the inner `CURRENT_PROJECT_CONTEXT:` project derived in build_current_context_blocks. No asymmetry.
    </behavior>
    <action>
      Step 1 — Grep audit:
      ```
      rg -n "build_current_context_blocks" src/
      rg -n "derive_current_repo_names" src/
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder (signoff.rs callers per the Plan 04 DRY note) — transparently fixed.
      - Anything else — investigate and either confirm safe or extend the fix.

      Step 2 — Inspect `src/live/signoff.rs::compose_init_signoff_payload`: confirm it actually invokes `build_current_context_blocks(self_id)` (not a divergent copy of the project-resolution logic). If it does, no edit needed. If it has its own resolver, port the Task 2 logic there too (or refactor to call build_current_context_blocks).

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site found in step 1. For each, confirm it is either:
      (a) Not a haiku/signoff prompt builder (so the D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way.
      Document findings in the plan SUMMARY.

      Step 4 — Rebuild the binary:
      ```
      cargo build --release
      ```

      Step 5 — Manual smoke trace (run_in_background: true since active perch):
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` and trigger an echo_commune fire (either wait for cadence or send a deliberate `.more-done` trigger).
      - Locate the next [PSYCHE] commune EVENT in the log.
      - Confirm: the outer EVENT envelope's `project="..."` attr matches whatever the new haiku-prompt build resolved as `CURRENT_PROJECT_CONTEXT` project name (read from haiku stdout or the rendered prompt). They MUST be the same string. Expected: both = `claude_skill_owl` for todlando.

      Step 6 — Verify the artifact destination:
      - After the fire, check `%LOCALAPPDATA%\spt\psyches\tracked\projects\` — `claude_skill_owl/` directory MUST now exist (lazy-created by route_two_slice on first write to `projects/claude_skill_owl/todlando.md`). Note: this verification only succeeds once Defect D is also fixed (Plan 02), because the haiku LLM still needs to be instructed to emit `<project-context>`. With ONLY this plan landed, the haiku gets the right project name but still won't necessarily emit the envelope. Document this dependency in the SUMMARY.
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | tail -10 ; rg -c "build_current_context_blocks|derive_current_repo_names" src/ | head -20</automated>
    </verify>
    <done>
      - Audit of `build_current_context_blocks` callers complete; SUMMARY documents each site and its disposition.
      - Audit of `derive_current_repo_names` callers complete; non-prompt-build sites confirmed safe.
      - `cargo build --release` succeeds.
      - Manual smoke confirms outbound EVENT `project` attr matches inner-block project (live trace logged in SUMMARY).
      - SUMMARY documents the Plan 02 dependency for full `projects/<name>/<self_id>.md` artifact creation.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | tracked-agents info.json read | Filesystem read of a JSON document that another process (Phase 24.1 wrapper bumps) writes |
  | haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `last_project_name` field in tracked-agents info.json | accept | Field is written by SPT itself (Phase 24.1 D-09) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. No security boundary crossed. |
  | T-25.3.01-I1 | Information disclosure | Project name leaked into haiku prompt | accept | Project name is the user's repo basename; same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed tracked-agents info.json crashes the resolver | mitigate | `read_tracked_agent_info_summary` returns Option and swallows JSON parse errors (existing behavior — Phase 24.1 sealed). The new helper inherits Option-return semantics; missing/malformed → None → fall back to cwd path. Resolver MUST NOT panic on any input. |
  | T-25.3.01-T2 | Tampering | `"tracked"` value written into `last_project_name` by an adversarial process to mask the real project | mitigate | The Task 1 helper explicitly rejects the literal `"tracked"` string. This is also the D-06 false positive guard; same code serves both threats. |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `read_tracked_last_project_name` is the new primary source.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback.
     - All three layers are exercised by unit tests.

  2. Grep audit complete:
     - `rg -n "build_current_context_blocks" src/` lists ≤2 call sites (echo_commune fire + signoff prompt build); both transparent.
     - `rg -n "derive_current_repo_names" src/` non-prompt-build sites confirmed unaffected.

  3. Binary smoke trace confirms outbound EVENT `project` attr === inner-block project name (no asymmetry).

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).
  </verification>

  <success_criteria>
  - `build_current_context_blocks("todlando")` returns a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `claude_skill_owl` (not `tracked`, not omitted) when tracked-agents info.json has `last_project_name = Some("claude_skill_owl")`.
  - No `cargo test` regressions.
  - `cargo build --release` succeeds; binary handoff to running todlando wrapper completes per Phase 18.4/18.5 mechanics.
  - SUMMARY documents the Plan 02 dependency: full `projects/<name>/<self_id>.md` artifact creation requires both B2 fix (this plan) AND D fix (Plan 02) to land.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Resolver layer diagram (tracked-info → legacy-perch → cwd-stamp).
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Smoke-trace log excerpt showing outbound EVENT `project=` attr matching inner-block project.
  - Dependency note: artifact creation at `projects/<name>/<self_id>.md` blocked on Plan 02 (Defect D — psyche.md absorption instruction).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context name=\"<name>\">...</project-context>`, it knows from psyche.md that the body persists to `projects/<name>/<self_id>.md` (NOT only to `live_context.md`)."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The project name token the Psyche LLM uses inside `<project-context name=\"...\">` matches the value resolved by Plan 01's upgraded `build_current_context_blocks` (= tracked-agents info.json `last_project_name`); no drift between producer-side teaching and consumer-side resolution."
      - "`plugin/spt/skills/commune/commune.md` (producer-side human guidance) and `plugin/spt/skills/signoff/SKILL.md` (signoff-side teaching) reference the same `<project-context name=\"...\">` token format taught in psyche.md."
      - "After binary rebuild + handoff, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New project-context detection + persistence instruction."
        contains: "<project-context"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (project name token convention)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM persistence instruction)"
        to: "src/owl/echo_commune.rs::build_current_context_blocks (Plan 01)"
        via: "shared project-name token convention (tracked-info last_project_name)"
        pattern: "CURRENT_PROJECT_CONTEXT"
      - from: "psyche.md absorption rules"
        to: "src/common/tracked.rs::route_two_slice"
        via: "two-slice envelope contract (live-context body → live_context.md; project-context body → projects/<name>/<self_id>.md)"
        pattern: "route_two_slice"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work landed at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms the existing teaching (lines 244-354) does cover both envelopes, but two gaps remain:
  1. **Absorption side gap (the primary fix):** The teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context name=\"X\">...</project-context>`, treat its body as project-X delta and merge it into your in-memory context keyed on project X." The user expectation in the debug doc is that incoming envelopes get persisted (Defect A is the wrapper-side counterpart of this; Defect D is the LLM-side counterpart). For the round-trip-via-LLM architecture to work, the Psyche LLM must DETECT inbound project envelopes, merge them, AND re-emit them on the next context-save.
  2. **Name-token convention gap:** The current emit rule (line 322) instructs the parser to route by `<cwd_project>` — but Plan 01 changed the producer-side resolver to use `last_project_name` from tracked-agents info.json (not cwd). The teaching needs to clarify: the project name is whatever appears in the prompt's `CURRENT_PROJECT_CONTEXT:` header (set by `build_current_context_blocks` which now reads `last_project_name`). The LLM should NOT try to re-derive the project name from cwd or any other source; it uses whatever the prompt provides.

  This plan also cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync at deploy time; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` (root) — strengthened absorption rules + name-token clarification.
  - `commune.md` + `signoff/SKILL.md` — producer-side aligned with psyche.md.
  - Binary rebuild + DEPLOY.ps1 (or in-place sync for skill files).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after Plan 01 + Plan 02 are both live.

  Purpose: Plan 01 unblocks the haiku from seeing the correct project name. Plan 02 unblocks the haiku LLM from actually emitting the `<project-context>` envelope. Without BOTH, the artifact never materializes on disk.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO `CURRENT_PROJECT_CONTEXT` block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — THIS IS THE WORD TO FIX (Plan 01 broke "cwd" semantics; it is now `last_project_name`).
  - `<context_save>` section (line 325-355) reinforces the emit contract every 3-5 messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section that teaches the LLM to detect and merge INBOUND project-context envelopes by name attr.
  - No rule that says "the project name to use in `<project-context name=\"...\">` is taken VERBATIM from the prompt's `CURRENT_PROJECT_CONTEXT:` header line; do not re-derive."

  From src/owl/echo_commune.rs::build_current_context_blocks (Plan 01-fixed state):
  - Emits `CURRENT_PROJECT_CONTEXT:\n<body>` in the prompt.
  - Project name resolved via `last_project_name` (Plan 01).
  - But the prompt does NOT explicitly carry the project name STRING as a separate header — it just emits the body. The Psyche LLM must therefore either: (a) trust the prompt's pairing implicitly (i.e. "whatever the prompt's CURRENT_PROJECT_CONTEXT is about, that IS the current project"), OR (b) the prompt needs to be enriched in build_current_context_blocks to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM has an explicit name token to echo back.

  Decision required at execute time (see Task 1 checkpoint): pick approach (a) or (b). Plan 04 builds on whichever choice is made here.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Project-name token surfacing in the haiku prompt</decision>
    <context>
      psyche.md currently teaches the LLM to emit `<project-context>` (no name attr) and have the parser route by cwd_project. Plan 01 made the resolver authoritative for project name (`last_project_name`), but the haiku prompt does NOT currently carry the resolved name as a string token the LLM can echo. Two options for closing this gap:

      Option A (LLM-implicit pairing) — `psyche.md` instructs the LLM: "whatever project the prompt's CURRENT_PROJECT_CONTEXT block is about, emit `<project-context>` (no name attr) and the receiver writes to `projects/<that name>/<self_id>.md`. The LLM does NOT need to know the name string; the writer (Rust side) re-resolves it via `last_project_name` at write time."

      Option B (explicit name token) — Modify `build_current_context_blocks` (Plan 01 file, already touched in Wave 1) to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM can read the name and emit `<project-context name=\"<X>\">...</project-context>`. Parser/writer reads the `name=` attr from the envelope.

      Implication for Plan 04 (Defect A wrapper routing): Option B lets the wrapper short-circuit inbound `<project-context name=\"X\">` directly to disk without consulting any other source. Option A means the wrapper must consult tracked-info too — same code path as the LLM-side write helper.
    </context>
    <options>
      <option id="option-a">
        <name>LLM-implicit pairing (no name attr; writer re-resolves)</name>
        <pros>Smaller psyche.md diff; no envelope-shape change; existing parse_two_slice / route_two_slice already work this way; Phase 25 Plans 01-04 invariants preserved.</pros>
        <cons>Plan 04 wrapper routing also needs to re-resolve project name from info.json (duplicates Plan 01's resolver call at a third site); inbound envelopes from cross-project agents could be misrouted if the receiver's `last_project_name` is stale.</cons>
      </option>
      <option id="option-b">
        <name>Explicit name token in `<project-context name=\"X\">`</name>
        <pros>Self-describing envelope; wrapper can route inbound to `projects/X/<self_id>.md` directly without filesystem lookups; survives cross-project messaging; matches the producer-side `<EVENT project=\"X\">` attribute convention.</pros>
        <cons>Requires changes to `build_current_context_blocks` (already touched in Plan 01 — adds a small wording diff), `parse_two_slice` (parse the `name=` attr), `route_two_slice` (consume the attr in the envelope rather than relying on a separate argument), and psyche.md emit rules; risk of breaking existing in-flight `<project-context>` envelopes if any are queued.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. The remaining tasks in this plan follow the chosen option.</resume-signal>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md aligned with the chosen option</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 in full (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. When an inbound payload contains `<project-context...>...</project-context>` (Option B) or `<project-context>...</project-context>` (Option A), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>` (Option A — receiver re-resolves name from prompt) or `<project-context name=\"X\">...</project-context>` (Option B — echo the name attr verbatim).

      3. Inbound envelope merging is ALWAYS active — you do not need a CURRENT_LIVE_CONTEXT / CURRENT_PROJECT_CONTEXT prompt block to merge inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. (Option B only) Name-token rule: the value of `name=` in `<project-context name=\"X\">` MUST come from one of two sources and ONLY these two:
         (a) The prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header line (when present — set by build_current_context_blocks).
         (b) The `name=` attr of an inbound `<project-context name=\"X\">` envelope you are echoing back.
         NEVER derive the name from cwd, file paths, or any other source.

      Update the existing `<output_envelope>` rule at psyche.md line 322 to remove the `<cwd_project>` phrasing and replace with `<project_name resolved by `build_current_context_blocks`>` (Option A) or `<name attr value>` (Option B). Cite the Plan 01 fix in a comment so future editors see the link.

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions and align with the chosen option.

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching, aligned with the chosen option from Task 1.

      Keep total prompt growth under +60 lines (psyche.md is context-window-critical for every Psyche session).
    </action>
    <verify>
      <automated>grep -c "project-context" psyche.md ; grep -c "live-context" psyche.md ; wc -l psyche.md</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-3 (and rule 4 if Option B).
      - `<output_envelope>` line 322 wording updated.
      - `<context_save>` section aligned with chosen option.
      - psyche.md total line count increase ≤60 lines.
      - No new pulse/timed-pulse/tool-permission rules introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches the human / Self-side agent how to compose a commune/signoff body. Confirm or update:

      1. The producer-side teaching matches the chosen option from Task 1:
         - Option A: producer emits `<project-context>...</project-context>` (no name attr) when the cwd is inside a tracked project.
         - Option B: producer emits `<project-context name=\"<project>\">...</project-context>` and the name comes from the same source as build_current_context_blocks (tracked-agents info.json `last_project_name`, or the local repo basename if the producer is the human-side Self agent — document this fallback explicitly).

      2. Both files reference the same routing destination: `projects/<name>/<self_id>.md` (the Phase 25 schema). Old references to `projects/<self_id>.md` (per-agent flat layout — the user's mistaken expectation from the debug doc) must be corrected.

      3. Both files cross-reference psyche.md's absorption rules with a one-line pointer ("Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules.").

      Do NOT introduce new commune subcommands, new flags, or new file-drop conventions. This is strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically a thinner wrapper; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1; no binary rebuild needed for skill-side changes. Mark the SUMMARY with this distinction.
    </action>
    <verify>
      <automated>grep -n "project-context" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md ; grep -n "projects/<.*>/.*\.md\|projects/.*/.*\.md" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<name>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - Producer-side teaching matches the chosen option (A or B) for the name attr convention.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: cargo build + DEPLOY + binary handoff + smoke check artifact creation</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe.

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire (.more-done window).
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (run_in_background: true).
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes.

      Step 5 — Confirm artifact creation on disk:
      ```
      ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify checkpoint — the LLM may need additional prompt tightening, or the route_two_slice writer may have an unrelated bug surfaced by this fix.
    </action>
    <verify>
      <automated>ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\" 2>$null ; test-path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped to v1.11.5 (or current+1).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 02 together: corrected project-name resolver in `build_current_context_blocks` (Plan 01) AND `psyche.md` absorption rules teaching the Psyche LLM to detect, merge, and re-emit `<project-context>` envelopes (this plan).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists.
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando, wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears (proves the absorption→merge→re-emit→route_two_slice round trip works end-to-end).
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=\"X\">` (Option B only) | mitigate | Add a validation rule in psyche.md: name must match `^[a-zA-Z0-9_.-]+$` and the receiver writer (route_two_slice) MUST sanitize before joining into a filesystem path. Existing Phase 25 D-10/D-11 attribute validation is the wider mitigation; this plan reuses that contract. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via `<project-context name>` echo | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14) and existing route_two_slice destination. No new surface. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild of existing crates. Cargo.lock not modified except by deterministic rebuild. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds.
  - DEPLOY.ps1 runs successfully; plugin version bumped.
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output.
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally (no regression).
  - Optional round-trip test: marker text from a deliberate inbound commune appears in the project context file after one full echo_commune cycle.
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-3 (+ rule 4 if Option B).
  - commune.md + signoff/SKILL.md aligned with chosen option.
  - Binary deployed; in-flight perches handed off.
  - `projects/<project>/<self_id>.md` artifact materializes on disk for at least one agent (todlando) after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Chosen option (A or B) and rationale.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt showing both envelopes in haiku output.
  - Artifact verification: `ls` of `projects/` directory contents.
  - Operator-verify response.
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/owl/poll.rs
    - src/owl/send.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract: which boundaries encode, which decode, and in what order."
      - "When a commune EVENT envelope arrives at the Psyche LLM, the body is either CONSISTENTLY encoded (outer tags AND inner envelope tags both escaped, `<br>` as literal text inside an encoded body) or CONSISTENTLY decoded (outer EVENT tag readable AND inner `<live-context>` / `<project-context>` tags readable, with newlines split on `<br>`). NEVER the mixed state observed at 16:58:53 (outer raw, inner encoded)."
      - "If the producer side has been over-encoding inner envelopes that already arrive escape-free from the LLM, that double-encode is identified and removed at the source — NOT compensated for by an extra decode pass downstream."
      - "If a wrapper relay path is adding a second encode pass between Self listener and Psyche delivery, that pass is identified and removed — relays are pure pass-throughs unless explicitly composing a new EVENT envelope."
      - "A new written-down decode contract lives in a single doc surface (e.g. `src/owl/poll.rs` module doc OR a sibling .md in `.planning/phases/25.3-*/`) so future agents have ONE source of truth."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites"
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs (or new tests/event_encoding_chain.rs)"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding"
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; must round-trip with at most ONE encode + ONE decode pair"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract must hold across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract. Either:
  - The producer is doing a SECOND encode pass on inner envelope tags only (e.g. the human-side commune body already contains `<live-context>` literal tags from the user, and the producer encodes them once for transport, while the OUTER `<EVENT>` is composed fresh post-encode so it stays raw). Then poll-side decode runs once and the result is the mixed state. The fix is: do NOT encode user-supplied inner envelope tags at producer time — they ARE the structure the consumer needs to parse.
  - The wrapper relay is doing an extra encode pass between Self listener and Psyche LLM stdin (introducing `&lt;` only on body content that already contained `<` and `>` post-spool-decode). The fix is: make the wrapper relay a pure pass-through.
  - The Self listener is decoding `<br>` to newline but NOT decoding `&lt;` (partial decode). The fix is: complete the decode chain in poll-side path.

  Today the project-wide encode/decode contract is split across multiple modules (`src/owl/poll.rs::event_body_escape`, `event_attr_escape`, `parse_event_from_attr`; `src/owl/echo_commune.rs::compose_echo_commune_payload`; `src/live/wrapper/mod.rs` relay; `src/live/commune.rs`) with no single document defining the full traversal. The documented contract (per the ROADMAP entry) is: `<br>` split first, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;` LAST. But there is no central enforcement and no end-to-end test asserting symmetry.

  This plan:
  1. Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff).
  2. Identifies the asymmetric site (the source of the leak).
  3. Fixes at the source — single encode at producer, single decode at consumer; no double-anything anywhere.
  4. Writes down the contract in a single doc surface as the authoritative reference.
  5. Adds an end-to-end test that captures a fabricated commune body, runs it through the full pipeline, and asserts the body delivered to the Psyche-bound stdin position has no mixed encoding state.

  Output:
  - Trace report (in SUMMARY) of where each encode and decode happens.
  - Single source-of-truth doc block (in `poll.rs` or a new `encoding.rs` module).
  - Source-of-truth fix at the asymmetric site.
  - End-to-end test guarding against regression.
  - Binary rebuild.

  Note: Plan 04 (Defect A) DEPENDS on this plan's outcome — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Plan 04 starts AFTER this plan settles the contract.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse of event_attr_escape on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites (rg results):
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose: attrs + body escape.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition: attr + body escape.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs attr escape (5-attr stamp).

  Decode call sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also a from-attr decoder; same as above? confirm).
  - (?) — body decode? There is NO body decode site documented yet — the body is intended to reach the Psyche LLM ENCODED, and the LLM is supposed to read `&lt;live-context&gt;` AS the literal tag-name? OR the LLM is supposed to read `<live-context>` (decoded)? psyche.md inspection (Plan 02) says the LLM emits DECODED tags but it's silent on what it CONSUMES. THIS IS THE CONTRACT GAP TO RESOLVE.

  The asymmetry the user observed (outer `<EVENT>` and `<br>` raw, inner `&lt;live-context&gt;` encoded) means SOMEONE is decoding the outer but not the inner — or someone is encoding the inner a second time after a first decode. Trace must identify which.

  Current `<br>` handling:
  - Encode side: `event_body_escape` converts `\n` → `<br>` LAST.
  - Decode side: no explicit `<br>` → `\n` decode found in grep. If consumer reads `<br>` literally, that's a BUG (or a documented "Psyche LLM understands `<br>` as newline" contract — which would explain why `<br>` is left as `<br>` while `&lt;` would need decoding for `<live-context>` tag detection).
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no file edits in this task)</files>
    <action>
      Run the following greps (run_in_background: true for any long ones; collate results inline):
      ```
      rg -n "event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr" src/
      rg -n "&amp;|&lt;|&gt;|&quot;|<br>" src/ --type rust
      rg -n "replace.*&amp;|replace.*&lt;" src/
      rg -n "deliver_body|deliver_body_anonymous|deliver_body_deferred" src/
      ```

      For each emit site (every `format!` or `write!` that constructs an EVENT envelope):
      - Note the file/line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site (every reader of a body that contains EVENT envelope structure):
      - Note the file/line.
      - Note what is decoded (attrs / body / both / neither).
      - Note what is done with the decoded value (LLM stdin / Self prompt inject / written to file).

      Specifically trace ONE commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop, Phase 30).
      2. Self listener (`src/owl/poll.rs`) detects the file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151 — attr escape ONLY, no body escape because the file path is an attr).
      3. Self listener delivers the file_drop EVENT via owl REST to the Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives the file_drop EVENT, parses kind+path, reads the file from disk (raw file contents — no encoding here since this is filesystem read), composes a `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER for body encode behavior.
      5. Wrapper sends the composed envelope to the Psyche LLM stdin via `resume_session_with_exit`.

      For the OUTBOUND echo_commune path (separate trace):
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH trace step record: encoded? decoded? pass-through? If a body crosses a step and the encoding state changes, that step is the suspect.

      Produce a CHAIN TABLE in the SUMMARY of the form:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD: encoded? raw?) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (NOT yet read in context-loading — read now from src/live/wrapper/mod.rs or wherever it lives). This is the prime suspect for asymmetric encoding because the inbound user body contains literal `<live-context>` tags from the human-side commune writer, but the outer `<EVENT>` is composed fresh.

      Identify the asymmetric site and document the root cause in the SUMMARY trace.
    </action>
    <verify>
      <automated>rg -c "compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload" src/ | head -20</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line and a specific reason ("X encodes body, but downstream Y also encodes" OR "X decodes inner tags but not outer" OR similar).
      - Root cause hypothesis stated explicitly and is testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Encoding contract direction — Psyche LLM consumes encoded or decoded inner envelopes?</decision>
    <context>
      The Psyche LLM is supposed to detect `<live-context>` and `<project-context>` tags in inbound payloads (per Plan 02 absorption rules). For that to work, the LLM must SEE the literal `<` and `>` characters — not `&lt;` / `&gt;`. So the consumer-side contract MUST be: at the Psyche stdin write site, all body content is fully decoded (no `&lt;` / `&gt;` / `&quot;` / `&amp;` entities; `<br>` either decoded to `\n` or kept literal — choose one and document).

      The producer-side encode is necessary because outer `<EVENT>` tag boundaries need to be unambiguous in the spool storage / TCP transport layer (otherwise a literal `<EVENT>` inside the body would corrupt the framing).

      So the contract is: encode at producer, decode at the LAST consumer boundary before LLM stdin. The question is WHERE the decode lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single decode site; all upstream layers (spool, TCP, poll, wrapper inner-poll) see encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool stores encoded, TCP carries encoded, but everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay.

      Option C — No decode at all; the Psyche LLM is taught (psyche.md) to handle `&lt;live-context&gt;` literally. Requires psyche.md change AND breaks the absorption rule in Plan 02. Reject.

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` directly on an inbound envelope, the body MUST be decoded first so `parse_two_slice` can find the literal `<live-context>` tag. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether it's already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary</name>
        <pros>Single decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-side encode-once symmetry.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication.</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Fix the asymmetric site identified in Task 1, aligned with the chosen option</name>
    <files>src/owl/echo_commune.rs, src/owl/poll.rs, src/live/wrapper/mod.rs, src/live/commune.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - A new helper `event_body_unescape` (inverse of `event_body_escape`) is added to `src/owl/poll.rs` if it does not already exist. The decode order is: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (matching the documented inverse of the encode order; cite the existing `parse_event_from_attr` precedent at poll.rs:720).
      - The decode is invoked at exactly ONE site (Option A: wrapper LLM-stdin write; Option B: poll site post-receive).
      - All producer-side double-encode bugs (if any) are removed at the producer — NOT compensated for downstream.
      - All wrapper relay paths are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Unit tests cover round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for a fabricated multi-tag body containing `<live-context>nested & content<br>line2</live-context>`.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691). Mirror the documented inverse order from `parse_event_from_attr`:
      ```
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```
      Doc-comment the function citing Defect C fix and the amp-last invariant.

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of:
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` in `src/live/wrapper/mod.rs` is encoding a body that was already encoded by the upstream Self listener), OR
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B), OR
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      The exact fix depends on Task 1's findings. Whichever applies, document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract in a single source-of-truth doc block at the top of `src/owl/poll.rs` (or extract to a new `src/owl/encoding.rs` module if the doc grows large). The block must cover:
      - Where encoding happens (every producer emit site).
      - Where decoding happens (the ONE chosen consumer site).
      - The decode order (`<br>` first, `&amp;` last).
      - What the spool / TCP / wrapper relay see (encoded throughout, OR encoded up to the poll site only).
      - Forbidden combinations (no double-encode; no half-decode).

      Step 4 — Add unit tests in `tests/native_owl.rs` (or `tests/encoding_chain.rs`):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly)

      Step 5 — Add an end-to-end test (new `tests/encoding_chain_end_to_end.rs`):
      - Fabricate a commune body containing both literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>` (the user-side commune writer composes these per Plan 02's producer-side teaching).
      - Run it through the full pipeline using existing test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that would hit Psyche stdin.
      - Assert: the final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND `<br>` is either fully decoded to `\n` (if Option A includes `<br>` decode) or left as literal `<br>` (if the contract preserves it). Assert NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape compose_commune_payload encoding_chain 2>&1 | tail -40</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with the amp-last invariant.
      - The asymmetric site is fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists at the top of `poll.rs` (or in `encoding.rs`).
      - 4 unit tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + smoke verify</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release`.

      Step 2 — Run full test suite: `cargo test --release` — confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests.

      Step 3 — Deploy via DEPLOY.ps1 -Bump patch. STOP before /reload-plugins (per CLAUDE.md).

      Step 4 — After user reload, send a commune to todlando containing inner envelope tags:
      ```
      $OWL deliver todlando <<'EOF'
      <live-context>marker-live-xyz</live-context>
      <project-context>marker-proj-abc</project-context>
      EOF
      ```

      Step 5 — Tail `todlando.log` and inspect the next `[PSYCHE] resume` entry. The body delivered to the Psyche LLM stdin (visible in the wrapper's log capture of the resume input) MUST show:
      - Outer EVENT tag readable (`<EVENT type="commune" ...>`).
      - Inner `<live-context>marker-live-xyz</live-context>` readable (literal `<` and `>`, NOT `&lt;` / `&gt;`).
      - Inner `<project-context>marker-proj-abc</project-context>` readable.
      - `<br>` either readable as literal `<br>` (if the contract preserved it) OR converted to actual newlines (depends on Option A's `<br>` decode choice).

      NO mixed state (outer readable + inner encoded). If mixed, the fix is incomplete — circle back to Task 1's trace.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - Full test suite passes (no regressions).
      - DEPLOY succeeds; plugin version bumped.
      - Smoke trace confirms NO asymmetric encoding state at Psyche stdin boundary.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. Single encode at producer; single decode at the chosen consumer boundary (Option A or B). End-to-end test guards against regression. Single source-of-truth contract doc block lives in `src/owl/poll.rs` (or `encoding.rs`).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `encoding.rs`). Confirm it describes the producer/consumer encode/decode points unambiguously.
      2. Inspect `todlando.log` for the smoke-test commune from Task 4. Confirm the body delivered to Psyche has uniform encoding state (NOT mixed).
      3. Run `cargo test --release` locally. Confirm encoding chain tests pass.
      4. Optional: examine the producer-side and consumer-side trace table in 25.3-03-SUMMARY.md — confirm only ONE encode and ONE decode in the full chain.
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean and the smoke trace shows uniform encoding, or describe the remaining asymmetry.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects (per chosen Option). |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. This plan PRESERVES that protection — the decode happens AFTER framing has done its job (at the LLM-stdin boundary). Tested via the round-trip and end-to-end tests. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode) | mitigate | Amp-last invariant documented in `event_body_unescape` doc comment + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly; existing EVENT-PART chunker (Phase 27 — `<br>` body cap not yet known; flag for follow-up if a body-size cap doesn't already exist). Add a TODO in the SUMMARY if no cap exists. |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place.
  - 4 unit tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Smoke trace on todlando shows uniform encoding state.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root cause statement.
  - Chosen option (A or B) and rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim).
  - Test names + pass evidence.
  - Smoke trace excerpt.
  - Plan 04 handoff note: "When `process_file_drop` receives a file_drop EVENT, the body is in state X (encoded / decoded / mixed-by-design)" — this answer is the foundation for Plan 04 Task 1.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 25.3-03
  files_modified:
    - src/live/wrapper/mod.rs
    - psyche.md
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<name>/<self_id>.md` on disk EITHER (Option 1) directly via wrapper-side `route_two_slice` call before LLM handoff, OR (Option 2) round-trip-via-LLM with psyche.md re-emit-on-next-fire hardening — the user choice is recorded and implemented exactly."
      - "The wrapper's `process_file_drop` is the gatekeeper for both options; no other inbound envelope route exists."
      - "Whichever option ships, an end-to-end test demonstrates: inbound payload with marker text → after one full cycle → marker text appears in `projects/<name>/<self_id>.md`."
      - "If Option 1 ships, the wrapper consumes a body in the state defined by Plan 03 (encoded or decoded — explicit, not assumed) and calls `route_two_slice` with correctly-stated inputs."
      - "If Option 2 ships, psyche.md `<absorption>` rules from Plan 02 are extended with a hard-guarantee re-emit clause: 'every fire that sees any inbound `<project-context>` MUST include a `<project-context>` in its output envelope, even if the body is unchanged'."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop with inbound route_two_slice call (Option 1) OR unchanged + comment block citing Option 2 rationale (Option 2)"
        contains: "route_two_slice"
      - path: "psyche.md"
        provides: "If Option 2: hardened re-emit-on-next-fire rule under <absorption>"
        contains: "re-emit"
      - path: "tests/native_owl.rs (or new wrapper_inbound_routing.rs)"
        provides: "End-to-end inbound envelope persistence test"
        contains: "inbound_project_context"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/common/tracked.rs::route_two_slice (Option 1) OR psyche.md absorption→re-emit (Option 2)"
        via: "single decision point for inbound envelope persistence"
        pattern: "route_two_slice|re-emit"
  ---

  <objective>
  Decide and implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md`. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — it never calls `route_two_slice` on the inbound envelope.

  The user's stated expectation ("envelope arrives → write to `projects/.../todlando.md`") is not implemented anywhere. Phase 25 Plan 04 wired `route_two_slice` on the EMIT side (echo_commune haiku fire) and on the signoff side, but NOT on the inbound receive path. The architecture relies on round-trip-via-LLM: the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk. When upstream defects (B2, D, C) corrupt that round-trip, inbound `<project-context>` is silently lost.

  Two architecturally valid options. The user chooses; this plan encodes both with full implementation details and the choice resolves at Task 1 checkpoint.

  This plan depends on Plan 03: the encoding contract MUST be settled before the wrapper can safely route an inbound body, because `route_two_slice` consumes the body via `parse_two_slice` which expects literal `<live-context>` / `<project-context>` tags (decoded form).

  Output:
  - User decision recorded at Task 1 checkpoint.
  - Implementation of the chosen option.
  - End-to-end test demonstrating inbound persistence (with both options, the success criterion is the same: marker text in `projects/<name>/<self_id>.md` after one full cycle).
  - Binary rebuild + deploy + smoke verification.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for both options. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind, or `compose_init_signoff_payload` for "signoff" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318) — Psyche LLM consumes it.
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315) — wrapper teardown path.
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags. EXPECTS DECODED INPUT.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - `<context_save>` section says emit every 3-5 messages.

  Plan 03 outcome (assumed available at execute time):
  - Encoding contract document specifies the state of the body at the wrapper file_drop boundary. Read 25.3-03-SUMMARY.md "Plan 04 handoff note" for the exact answer.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper-side inbound envelope routing strategy</decision>
    <context>
      The user must choose between two architecturally valid approaches. Both deliver the same observable success (marker text in `projects/<name>/<self_id>.md` after one cycle); they differ in WHEN and HOW.

      Option 1 — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call `route_two_slice(self_id, body, "commune-inbound", short)`.
      - The body is the file_drop file contents (raw filesystem read; encoding state per Plan 03's documented chain at this site).
      - If Plan 03 says the body is in encoded form here (e.g. Option B from Plan 03 decoded at poll instead), we may need to decode first. If Plan 03 says decoded, we route directly.
      - Pros: Synchronous persistence; inbound envelope hits disk immediately; no dependency on the LLM round-trip working correctly; survives a Psyche LLM that drops the envelope.
      - Cons: Loses the "Psyche merges in-memory" semantic — the LLM still receives the envelope and may re-emit a stale version that overwrites the just-routed body on the next echo_commune fire. Mitigated because `build_current_context_blocks` already reads `projects/<name>/<self_id>.md` at prompt-build time (per Plan 01 — verified at line 411-420 of the patched code), so the LLM sees the just-routed content as CURRENT_PROJECT_CONTEXT and merges from there. Still, double-write through the LLM round-trip could overwrite with stale content if the LLM's output omits the project-context section.

      Option 2 — Confirm round-trip-via-LLM is intended; harden psyche.md re-emit.
      - Do NOT modify `process_file_drop`.
      - Extend psyche.md `<absorption>` section (built in Plan 02) with a hard-guarantee re-emit clause: "Every fire that sees any inbound `<project-context>` envelope MUST include a `<project-context>` in its output context-save, even if the body is unchanged."
      - Optionally: add a Rust-side guard in echo_commune that detects "we have CURRENT_PROJECT_CONTEXT but the haiku's output lacks `<project-context>`" as a regression signal (log warning; consider future enforcement).
      - Pros: Preserves the merge-in-LLM semantic; no double-write race; smaller code surface.
      - Cons: Relies on the LLM following instructions; if the LLM occasionally drops the envelope (as todlando did per the original debug evidence), data is lost. Defends only with prompt engineering, not invariant.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_two_slice before LLM handoff</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune.</pros>
        <cons>Double-write through round-trip; relies on build_current_context_blocks reading from disk (already true per Plan 01); needs Plan 03's decoded-body answer to know whether to decode first.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM + psyche.md re-emit hardening</name>
        <pros>Smaller code surface; preserves merge-in-LLM architectural intent; no race.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss; harder to test.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 or option-2. Subsequent tasks implement exactly the chosen option.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (Option 1 path): Wire route_two_slice in process_file_drop with Plan 03 decode awareness</name>
    <files>src/live/wrapper/mod.rs</files>
    <behavior>
      - When `process_file_drop("commune", path)` is invoked with a file containing `<project-context>marker-abc</project-context>` (decoded form, as filesystem read returns), `route_two_slice` is called with the body and the source string `"commune-inbound"`.
      - `projects/<name>/<self_id>.md` exists on disk after the call returns (assuming Plan 01's `last_project_name` resolves correctly for the target self_id).
      - The existing LLM-handoff path is preserved — the body STILL flows into `resume_session_with_exit` so the Psyche LLM can update its in-memory context. Route is a SIDE EFFECT, not a replacement.
      - If `route_two_slice` returns Err (e.g. project name unresolvable for this self_id), log a structured `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)` and proceed to LLM handoff. NEVER abort the file_drop processing on route failure.
      - "signoff" kind is NOT changed (signoff already routes via the dedicated signoff path; this plan only addresses inbound commune file_drops).
    </behavior>
    <action>
      SKIP this task if Option 2 was chosen at Task 1. Proceed to Task 3 (Option 2 path).

      Read `src/live/wrapper/mod.rs` lines 1231-1350 in full (process_file_drop is the entire scope).

      Confirm Plan 03's handoff note: at the point of `let body = std::fs::read_to_string(path)?` (line 1247), is the body in DECODED form? Filesystem reads are always raw bytes; the producer-side writer (Phase 30 `/spt:commune` skill) writes user prose with literal `<live-context>` / `<project-context>` tags — so YES, decoded form at this site. Plan 03's contract should confirm.

      Modify `process_file_drop`'s "commune" arm:

      Before the `let envelope = match kind { ... }` block (currently at line 1278), insert:

      ```
      // 25.3-04 Option 1: route inbound commune body to two-slice before LLM
      // handoff. route_two_slice consumes decoded form (parse_two_slice expects
      // literal <live-context> / <project-context> tags); filesystem read yields
      // decoded body verbatim, so no decode pass needed here. Per Plan 03
      // encoding contract, the wrapper file_drop site sees decoded bodies.
      //
      // This is a SIDE EFFECT, not a replacement: the body still flows into
      // resume_session_with_exit below so the Psyche LLM updates its in-memory
      // context. The route_two_slice call hardens against LLM drops (per
      // todlando 2026-05-22 debug). Failures are non-fatal — LLM handoff
      // continues regardless.
      if kind == "commune" {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());
          if let Err(e) = crate::common::tracked::route_two_slice(
              &self.self_id,
              &body,
              "commune-inbound",
              &short,
          ) {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              ));
          } else {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound)",
                  self.self_id
              ));
          }
      }
      ```

      Reuse the "short" derivation pattern from echo_commune.rs:848-853 verbatim (DRY — same short composition rule).

      Add unit tests in the existing wrapper tests block OR a new `tests/wrapper_inbound_routing.rs`:
      - `process_file_drop_commune_routes_two_slice_with_inbound_project_context`
      - `process_file_drop_commune_continues_llm_handoff_when_route_fails`
      - `process_file_drop_signoff_does_not_route_two_slice` (negative — signoff path unchanged)
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` (timing assertion: route call site precedes resume_session_with_exit call site)

      Tests will need to construct a test wrapper instance with mocked LLM handoff (existing test scaffolds in `file_drop_handler_tests` module — extend those).
    </action>
    <verify>
      <automated>cargo test --release process_file_drop_commune_routes_two_slice 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `process_file_drop` "commune" arm calls `route_two_slice` BEFORE composing the envelope and handing off to LLM.
      - Route failures are logged and non-fatal.
      - "signoff" arm unchanged.
      - 4 unit tests pass.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path): Harden psyche.md re-emit guarantee + optional Rust regression detector</name>
    <files>psyche.md, src/owl/echo_commune.rs</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1. Proceed to Task 4.

      Read `psyche.md` `<absorption>` section (added in Plan 02) and `<context_save>` section.

      In the `<absorption>` section, add a new rule (rule N, where N is one past Plan 02's last rule):

      "**Re-emit guarantee:** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged from the prior context-save. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer (route_two_slice) treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Optional Rust-side regression detector (only if scope budget allows in this plan, otherwise defer to a follow-up):

      In `src/owl/echo_commune.rs::run_echo_commune`, after the haiku stdout is captured and before `route_two_slice` is invoked at line 854, add a check:
      - If the haiku prompt included `CURRENT_PROJECT_CONTEXT:` AND the haiku output did NOT contain `<project-context>`, log a structured warning:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (re-emit guarantee violated; see 25.3-04)", self_id);
        ```
      - This is observability, not enforcement. A future plan can decide to ENFORCE (e.g. retry the haiku with a stricter prompt).

      The detection is a simple substring check: `prompt.contains("CURRENT_PROJECT_CONTEXT:")` and `output.contains("<project-context>")`. No new dependencies.
    </action>
    <verify>
      <automated>grep -c "Re-emit guarantee" psyche.md ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - psyche.md `<absorption>` section has the new "Re-emit guarantee" rule.
      - (Optional) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (both options share this)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — DEPLOY.ps1 -Bump patch. STOP before /reload-plugins.

      Step 4 — After user reload, send a deliberate inbound commune to todlando containing a fresh marker:
      ```
      $OWL deliver todlando <<'EOF'
      <project-context>marker-25.3-04-Z (timestamp $(date +%s))</project-context>
      EOF
      ```

      Step 5 — Verification (option-dependent):

      **Option 1 expected:** within seconds (before the next echo_commune fire), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`. The `[FILE-DROP] route_two_slice ok for todlando (commune-inbound)` log line appears in todlando.log.

      **Option 2 expected:** within ONE echo_commune cycle (cadence-driven or .more-done-triggered), the marker text appears in the same file. The wrapper-side route_two_slice log line does NOT appear (path unchanged).

      Step 6 — Inspect the on-disk file content and confirm the marker text round-tripped intact (no encoding artifacts from Plan 03's chain).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md" 2>$null | head -20</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` per the chosen option's expected timing.
      - Log lines match the chosen option's expected behavior.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in order:
      - Plan 01 (B2): project resolver correctness.
      - Plan 02 (D): psyche.md absorption + emission teaching.
      - Plan 03 (C): encoding contract uniformity.
      - Plan 04 (A, this plan): inbound envelope routing decision (Option 1 wrapper-direct OR Option 2 LLM-round-trip-with-re-emit-guarantee).

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist to `projects/<name>/<self_id>.md` on disk, consistently and observably.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to any live agent (todlando is the canonical test target).
      2. Wait the appropriate cycle (Option 1: immediate; Option 2: next echo_commune fire — up to 15 minutes for cadence-driven; sub-minute for .more-done-triggered).
      3. Inspect `projects/<project>/<self_id>.md` — marker text MUST appear.
      4. Inspect todlando.log for the chosen option's expected log line presence/absence.
      5. Send a SECOND `<project-context>` with a different marker — confirm both markers either coexist (LLM merge behavior) or the second overwrites the first (route_two_slice atomic write). Either is acceptable; document which actually happens.
      6. Confirm `live_context.md` continues to update for the same agent (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the inbound envelope persistence works end-to-end per the chosen option, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1 only) | Wrapper-side direct write to `projects/<name>/<self_id>.md`. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | `route_two_slice` consumes the body — Phase 25 D-10/D-11 already mandated input sanitization on the project name. The project name comes from `last_project_name` (resolver, Plan 01) not from the envelope body — body never controls destination path. Plan 02's Option B (if chosen) would have a `name=` attr that DOES need sanitization; that mitigation lives in Plan 02. This plan inherits both. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with the LLM round-trip) | accept | If Option 1 ships and the LLM round-trip later emits a stale `<project-context>` body, the file content reverts. Mitigated because Plan 01's build_current_context_blocks reads the current file content into the haiku prompt — so the LLM sees the just-written content as baseline. Still, a misbehaving LLM could regress; the operator-verify checkpoint surfaces this. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes (atomic write + git commit). Same risk profile as existing echo_commune fires. |
  | T-25.3.04-R1 | Repudiation | LLM-driven re-emit silently drops `<project-context>` (Option 2) | mitigate (partial) | The Rust-side `[ECHO-WARN]` detector (Task 3) logs prompt-output asymmetry. Operator visible via tail of logs. Future enforcement could retry the haiku. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision verbatim.
  - The chosen option's task (Task 2 or Task 3) completes; the other is skipped.
  - Task 4 deploys + smoke-verifies end-to-end.
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded.
  - Chosen option implemented exactly (no inadvertent crossover behavior).
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within the option-appropriate window.
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 or 2) and the user's rationale (verbatim from the Task 1 checkpoint response).
  - Diff summary for the implementation file(s).
  - End-to-end smoke trace.
  - Inspection of `projects/claude_skill_owl/todlando.md` showing the marker text.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 02, 03, 04) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist reliably).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed
  3. **Concerns** — Potential issues, gaps, risks. **MARK EACH CONCERN WITH SEVERITY: HIGH, MEDIUM, or LOW.** HIGH = will cause plan failure or shipping bug; MEDIUM = significant risk requiring mitigation; LOW = nice-to-have or stylistic.
  4. **Suggestions** — Specific improvements
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on: missing edge cases, dependency ordering, scope creep, security, performance, whether plans achieve phase goals. Output in markdown.

first_user_message:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Plans to Review

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  must_haves:
    truths:
      - "When a live agent has `last_project_name` set in tracked-agents info.json, `build_current_context_blocks` resolves the project to that value (NOT `tracked`, NOT psyche_dir basename)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` returned by `derive_current_repo_names()` D-06 fallback is rejected as a false positive and the project-block is omitted instead."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new helper used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via the `build_current_context_blocks` helper (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-first project resolution + `tracked` rejection"
        contains: "last_project_name"
      - path: "tests/native_owl.rs"
        provides: "Regression tests for B2 fix: info.json wins; tracked rejected; missing info.json falls through"
        contains: "build_current_context_blocks"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::tracked_agent_info_path + TrackedAgentInfo.last_project_name"
        via: "typed read of tracked-agents info.json"
        pattern: "tracked_agent_info_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md`. The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) resolves the project name via `derive_current_repo_names()`, which reads `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and route_two_slice writes to `projects/tracked/<self_id>.md` (or — empirically — drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  The same fire pass at line 869 already correctly resolves via `resolve_self_project_stamp(self_id)`, which is supposed to read Self's project from info.json. But that helper reads the PERCH info.json `project` field (line 132–139) — a field that is never populated — and falls back to cwd-stamp. Phase 24.1 D-09 lifted the canonical `last_project_name` field to the TRACKED-AGENTS info.json (`tracked_agent_info_path(self_id)` → `agents/<id>/info.json`, struct `TrackedAgentInfo.last_project_name`). The TODO at echo_commune.rs:108 explicitly flags this: "use `last_project_name` from tracked agents/info.json once available; current v1.8 fallback = psyche_dir basename via stamp()."

  This plan:
  1. Upgrades the project-name resolver to prefer tracked-agents info.json `last_project_name` over the unused perch info.json `project` field.
  2. Wires `build_current_context_blocks` through the corrected resolver (currently it bypasses the helper entirely and goes straight to `derive_current_repo_names()`).
  3. Adds a `"tracked"` rejection guard so the D-06 false positive can never leak through even when info.json resolution fails.
  4. Audits the two other callers of `build_current_context_blocks` (signoff prompt-build path; any pulse/init context assembly site) to confirm they pick up the fix transparently.

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name, so no downstream writer is ever exercised. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/owl/echo_commune.rs` — corrected `build_current_context_blocks` + supporting helper.
  - New unit tests in `tests/native_owl.rs` (or sibling) covering info.json hit / info.json miss / `tracked` rejection.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers the executor needs. Extracted from src/common/owlery.rs + src/common/types.rs. -->
  <!-- The fix wires build_current_context_blocks to consume TrackedAgentInfo.last_project_name. -->

  From src/common/types.rs (line 72-90):
  ```rust
  /// in this exact sequence. `last_project_name` is `Option<String>` so
  pub struct TrackedAgentInfo {
      pub agent_id: String,
      pub last_started: Option<String>,
      pub last_machine_name: Option<String>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub last_project_name: Option<String>,
      pub machine_history: Vec<MachineHistoryEntry>,
      pub project_history: Vec<ProjectHistoryEntry>,
  }
  ```

  From src/common/owlery.rs (line 901, 1054-1075):
  ```rust
  pub fn tracked_agent_info_path(agent_id: &str) -> PathBuf;
  // Pure path resolver: $SPT_HOME/psyches/tracked/agents/<agent_id>/info.json
  // Does NOT create the directory; safe to call on missing-agent paths.

  pub struct TrackedAgentInfoSummary {
      pub last_project_name: Option<String>,
      // ... other Phase 24.1 fields
  }
  // Lightweight read-only projection.
  ```

  From src/common/owlery.rs (line 1253):
  ```rust
  pub fn read_tracked_agent_info_summary(perch_id: &str) -> Option<TrackedAgentInfoSummary>;
  // Tracked-first read; returns None if tracked info.json missing/malformed.
  ```

  From src/common/owlery.rs (`project_worktree_path` — used at echo_commune.rs:415):
  ```rust
  pub fn project_worktree_path(project_name: &str) -> PathBuf;
  // $SPT_HOME/psyches/tracked/projects/<project_name>/
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written; the (a) branch always returns None; falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — totally bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today, but Phase 24.1 means it CAN get the right name if the helper is upgraded.
  </interfaces>

  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Upgrade project-name resolution to read tracked-agents info.json last_project_name</name>
    <files>src/owl/echo_commune.rs, tests/native_owl.rs</files>
    <behavior>
      - When `tracked_agent_info_path(self_id)` exists AND its TrackedAgentInfo has `last_project_name = Some("claude_skill_owl")`, the resolver returns `"claude_skill_owl"`.
      - When `tracked_agent_info_path(self_id)` exists but `last_project_name` is None / empty / missing, the resolver returns None (does NOT fall back to cwd-stamp at the resolver level — caller decides).
      - When `tracked_agent_info_path(self_id)` does not exist (path missing), the resolver returns None.
      - When `last_project_name = Some("tracked")` (defensive — should not happen but guard regardless), the resolver returns None (reject the D-06 false positive at the source).
      - The existing `resolve_self_project_stamp(self_id)` (line 110) is refactored to consult the new helper FIRST, then fall back to the legacy perch info.json `project` read (kept for back-compat with any populated field), then fall back to `crate::common::git::stamp()` cwd basename.
      - The TODO comment at line 108 is removed (it is being fulfilled here).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 80-140 in full.

      Add a new helper after `read_self_project_from_info` (around line 140):
      ```
      fn read_tracked_last_project_name(self_id: &str) -> Option<String>
      ```
      that calls `crate::common::owlery::read_tracked_agent_info_summary(self_id)` and returns `summary.last_project_name` after filtering out empty strings AND the literal `"tracked"` (case-sensitive — `tracked` is the known D-06 false positive; any agent that genuinely has a project named `tracked` is the user's problem). Document the `"tracked"` rejection with an inline comment citing `.planning/debug/todlando-project-context-not-persisted.md` Defect B2 and the D-06 false positive origin.

      Refactor `resolve_self_project_stamp` (line 110-127) to consult the new helper FIRST:
      1. Try `read_tracked_last_project_name(self_id)` — if Some, use that name; build stamp via `crate::common::git::stamp()` and overwrite `s.project = name`.
      2. Else try `read_self_project_from_info(self_id)` (existing legacy perch field — kept for forward compat; if a future phase populates it, it still works).
      3. Else return plain `crate::common::git::stamp()` (existing fallback; will be `tracked` from psyche_dir).

      Remove the TODO comment at lines 108-109 (it is being fulfilled).

      DO NOT modify `build_current_context_blocks` in this task — that is Task 2. Keep this task scoped to the resolver upgrade so the existing outbound-stamp consumer at line 869 picks up the fix transparently and we can test the helper in isolation.

      Add unit tests in `tests/native_owl.rs` (or create a sibling `tests/echo_commune_resolver.rs` if scope-isolation is cleaner — choose based on existing test-file conventions; if `tests/native_owl.rs` has resolver-shaped tests already, extend it):
      - `read_tracked_last_project_name_returns_name_when_info_json_has_last_project_name`
      - `read_tracked_last_project_name_returns_none_when_info_json_missing`
      - `read_tracked_last_project_name_returns_none_when_last_project_name_field_absent`
      - `read_tracked_last_project_name_rejects_literal_tracked_false_positive`
      - `resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field`

      Note: `read_tracked_last_project_name` is private to `echo_commune.rs`; tests use the `#[cfg(test)] mod tests` block at the bottom of the file (already exists per existing tests at line 1736+). Place new helper tests there alongside the existing escape-helper tests. The `tests/native_owl.rs` integration-style tests are for end-to-end stamping behavior — keep those for resolver-via-stamp assertions if needed.
    </action>
    <verify>
      <automated>cargo test --release --lib resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field read_tracked_last_project_name 2>&1 | tail -30</automated>
    </verify>
    <done>
      - New helper `read_tracked_last_project_name` exists and is unit-tested.
      - `resolve_self_project_stamp` consults the new helper FIRST.
      - TODO at line 108-109 removed.
      - 5 unit tests pass.
      - `cargo build --release` succeeds (binary rebuild required for downstream task; this build is the witness).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks to use the corrected resolver + reject tracked false positive</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked with cwd=psyche_dir, where tracked-agents info.json for `todlando` has `last_project_name = Some("claude_skill_owl")`, produces a prompt containing `CURRENT_PROJECT_CONTEXT:` followed by the content of `projects/claude_skill_owl/todlando.md` (or the first-commune literal if the file is absent).
      - The same call when info.json has `last_project_name = None` AND `derive_current_repo_names()` returns `["tracked"]` produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block (the `"tracked"` D-06 fallback is rejected; haiku correctly sees "no tracked project" and emits only `<live-context>`).
      - The same call when info.json is missing AND `derive_current_repo_names()` returns `["my_real_repo"]` produces a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `my_real_repo` (the cwd fallback path still works for non-haiku callers — e.g. signoff prompt-build invoked from a real project cwd).
      - Outbound EVENT stamping at line 869 (resolve_self_project_stamp) and the new inner-block resolution at line 411 BOTH read the same project name in the same fire pass. No asymmetry.
    </behavior>
    <action>
      Modify `src/owl/echo_commune.rs::build_current_context_blocks` (lines 399-423).

      Replace the `derive_current_repo_names().first()` call at line 411 with a layered resolver:

      Step 1 — Try tracked-agents info.json first via the new helper from Task 1 (`read_tracked_last_project_name(self_id)`). If Some(name), use that.

      Step 2 — Else fall back to `owlery::derive_current_repo_names().first()` (the existing cwd-derived path), BUT add a guard: if the returned name is the literal `"tracked"`, treat it as None (reject the D-06 false positive per Defect B2). This guard is belt-and-braces — Task 1 already filters in the helper, but this second guard ensures the cwd-only path also cannot leak `"tracked"`.

      Step 3 — If no name resolved, omit the project block entirely (existing behavior preserved — emits `CURRENT_LIVE_CONTEXT` only, haiku correctly emits only `<live-context>` per psyche.md rule 2).

      Preserve the existing first-commune-literal fallback for the file-read step (when project name resolves but the `projects/<name>/<self_id>.md` file is absent → emit block with `(none — first commune in project)`).

      Update the doc comment block on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (tracked-info → cwd → omit).
      - Keep the existing Plan 04 Task 4 DRY note about the signoff prompt builder reusing this helper.
      - Note that outbound EVENT stamping at run_echo_commune line 869 and inner-block resolution here MUST stay in sync; both consume `read_tracked_last_project_name` first.

      Add unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_tracked_info_last_project_name`
      - `build_current_context_blocks_rejects_tracked_d06_false_positive`
      - `build_current_context_blocks_omits_block_when_no_project_resolved`
      - `build_current_context_blocks_uses_first_commune_literal_when_project_file_missing`

      Tests will need to set up a temp SPT_HOME with a tracked agent info.json. Existing tests in this file already do this pattern (search for `tracked_agent_info_path` usage in test code); follow the established harness.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `read_tracked_last_project_name` first.
      - `"tracked"` literal is explicitly rejected at the cwd-fallback step.
      - 4 unit tests pass.
      - Doc comment updated with Defect B2 attribution + sync invariant with line 869.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Audit shared callers + rebuild binary + manual smoke-test stamping symmetry</name>
    <files>src/live/signoff.rs, src/owl/echo_commune.rs</files>
    <behavior>
      - `crate::live::signoff::compose_init_signoff_payload` (the Plan 04 DRY reuse site at echo_commune.rs:397) calls `build_current_context_blocks` and therefore picks up the fix transparently — no code change needed there, but confirm via grep + trace.
      - No other caller of `build_current_context_blocks` exists OR each additional caller is documented and verified to handle the new "may omit project block" behavior correctly.
      - No other caller of `derive_current_repo_names()` in the haiku/signoff prompt-build path relies on the `"tracked"` fallback as intended behavior. (Confirmed by grep + reading each call site; document findings inline in the SUMMARY.)
      - After `cargo build --release` + binary handoff, a manual smoke trace shows: outbound EVENT envelope `project=` attr (from resolve_self_project_stamp at line 869) matches the inner `CURRENT_PROJECT_CONTEXT:` project derived in build_current_context_blocks. No asymmetry.
    </behavior>
    <action>
      Step 1 — Grep audit:
      ```
      rg -n "build_current_context_blocks" src/
      rg -n "derive_current_repo_names" src/
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder (signoff.rs callers per the Plan 04 DRY note) — transparently fixed.
      - Anything else — investigate and either confirm safe or extend the fix.

      Step 2 — Inspect `src/live/signoff.rs::compose_init_signoff_payload`: confirm it actually invokes `build_current_context_blocks(self_id)` (not a divergent copy of the project-resolution logic). If it does, no edit needed. If it has its own resolver, port the Task 2 logic there too (or refactor to call build_current_context_blocks).

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site found in step 1. For each, confirm it is either:
      (a) Not a haiku/signoff prompt builder (so the D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way.
      Document findings in the plan SUMMARY.

      Step 4 — Rebuild the binary:
      ```
      cargo build --release
      ```

      Step 5 — Manual smoke trace (run_in_background: true since active perch):
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` and trigger an echo_commune fire (either wait for cadence or send a deliberate `.more-done` trigger).
      - Locate the next [PSYCHE] commune EVENT in the log.
      - Confirm: the outer EVENT envelope's `project="..."` attr matches whatever the new haiku-prompt build resolved as `CURRENT_PROJECT_CONTEXT` project name (read from haiku stdout or the rendered prompt). They MUST be the same string. Expected: both = `claude_skill_owl` for todlando.

      Step 6 — Verify the artifact destination:
      - After the fire, check `%LOCALAPPDATA%\spt\psyches\tracked\projects\` — `claude_skill_owl/` directory MUST now exist (lazy-created by route_two_slice on first write to `projects/claude_skill_owl/todlando.md`). Note: this verification only succeeds once Defect D is also fixed (Plan 02), because the haiku LLM still needs to be instructed to emit `<project-context>`. With ONLY this plan landed, the haiku gets the right project name but still won't necessarily emit the envelope. Document this dependency in the SUMMARY.
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | tail -10 ; rg -c "build_current_context_blocks|derive_current_repo_names" src/ | head -20</automated>
    </verify>
    <done>
      - Audit of `build_current_context_blocks` callers complete; SUMMARY documents each site and its disposition.
      - Audit of `derive_current_repo_names` callers complete; non-prompt-build sites confirmed safe.
      - `cargo build --release` succeeds.
      - Manual smoke confirms outbound EVENT `project` attr matches inner-block project (live trace logged in SUMMARY).
      - SUMMARY documents the Plan 02 dependency for full `projects/<name>/<self_id>.md` artifact creation.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | tracked-agents info.json read | Filesystem read of a JSON document that another process (Phase 24.1 wrapper bumps) writes |
  | haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `last_project_name` field in tracked-agents info.json | accept | Field is written by SPT itself (Phase 24.1 D-09) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. No security boundary crossed. |
  | T-25.3.01-I1 | Information disclosure | Project name leaked into haiku prompt | accept | Project name is the user's repo basename; same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed tracked-agents info.json crashes the resolver | mitigate | `read_tracked_agent_info_summary` returns Option and swallows JSON parse errors (existing behavior — Phase 24.1 sealed). The new helper inherits Option-return semantics; missing/malformed → None → fall back to cwd path. Resolver MUST NOT panic on any input. |
  | T-25.3.01-T2 | Tampering | `"tracked"` value written into `last_project_name` by an adversarial process to mask the real project | mitigate | The Task 1 helper explicitly rejects the literal `"tracked"` string. This is also the D-06 false positive guard; same code serves both threats. |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `read_tracked_last_project_name` is the new primary source.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback.
     - All three layers are exercised by unit tests.

  2. Grep audit complete:
     - `rg -n "build_current_context_blocks" src/` lists ≤2 call sites (echo_commune fire + signoff prompt build); both transparent.
     - `rg -n "derive_current_repo_names" src/` non-prompt-build sites confirmed unaffected.

  3. Binary smoke trace confirms outbound EVENT `project` attr === inner-block project name (no asymmetry).

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).
  </verification>

  <success_criteria>
  - `build_current_context_blocks("todlando")` returns a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `claude_skill_owl` (not `tracked`, not omitted) when tracked-agents info.json has `last_project_name = Some("claude_skill_owl")`.
  - No `cargo test` regressions.
  - `cargo build --release` succeeds; binary handoff to running todlando wrapper completes per Phase 18.4/18.5 mechanics.
  - SUMMARY documents the Plan 02 dependency: full `projects/<name>/<self_id>.md` artifact creation requires both B2 fix (this plan) AND D fix (Plan 02) to land.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Resolver layer diagram (tracked-info → legacy-perch → cwd-stamp).
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Smoke-trace log excerpt showing outbound EVENT `project=` attr matching inner-block project.
  - Dependency note: artifact creation at `projects/<name>/<self_id>.md` blocked on Plan 02 (Defect D — psyche.md absorption instruction).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context name=\"<name>\">...</project-context>`, it knows from psyche.md that the body persists to `projects/<name>/<self_id>.md` (NOT only to `live_context.md`)."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The project name token the Psyche LLM uses inside `<project-context name=\"...\">` matches the value resolved by Plan 01's upgraded `build_current_context_blocks` (= tracked-agents info.json `last_project_name`); no drift between producer-side teaching and consumer-side resolution."
      - "`plugin/spt/skills/commune/commune.md` (producer-side human guidance) and `plugin/spt/skills/signoff/SKILL.md` (signoff-side teaching) reference the same `<project-context name=\"...\">` token format taught in psyche.md."
      - "After binary rebuild + handoff, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New project-context detection + persistence instruction."
        contains: "<project-context"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (project name token convention)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM persistence instruction)"
        to: "src/owl/echo_commune.rs::build_current_context_blocks (Plan 01)"
        via: "shared project-name token convention (tracked-info last_project_name)"
        pattern: "CURRENT_PROJECT_CONTEXT"
      - from: "psyche.md absorption rules"
        to: "src/common/tracked.rs::route_two_slice"
        via: "two-slice envelope contract (live-context body → live_context.md; project-context body → projects/<name>/<self_id>.md)"
        pattern: "route_two_slice"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work landed at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms the existing teaching (lines 244-354) does cover both envelopes, but two gaps remain:
  1. **Absorption side gap (the primary fix):** The teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context name=\"X\">...</project-context>`, treat its body as project-X delta and merge it into your in-memory context keyed on project X." The user expectation in the debug doc is that incoming envelopes get persisted (Defect A is the wrapper-side counterpart of this; Defect D is the LLM-side counterpart). For the round-trip-via-LLM architecture to work, the Psyche LLM must DETECT inbound project envelopes, merge them, AND re-emit them on the next context-save.
  2. **Name-token convention gap:** The current emit rule (line 322) instructs the parser to route by `<cwd_project>` — but Plan 01 changed the producer-side resolver to use `last_project_name` from tracked-agents info.json (not cwd). The teaching needs to clarify: the project name is whatever appears in the prompt's `CURRENT_PROJECT_CONTEXT:` header (set by `build_current_context_blocks` which now reads `last_project_name`). The LLM should NOT try to re-derive the project name from cwd or any other source; it uses whatever the prompt provides.

  This plan also cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync at deploy time; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` (root) — strengthened absorption rules + name-token clarification.
  - `commune.md` + `signoff/SKILL.md` — producer-side aligned with psyche.md.
  - Binary rebuild + DEPLOY.ps1 (or in-place sync for skill files).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after Plan 01 + Plan 02 are both live.

  Purpose: Plan 01 unblocks the haiku from seeing the correct project name. Plan 02 unblocks the haiku LLM from actually emitting the `<project-context>` envelope. Without BOTH, the artifact never materializes on disk.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO `CURRENT_PROJECT_CONTEXT` block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — THIS IS THE WORD TO FIX (Plan 01 broke "cwd" semantics; it is now `last_project_name`).
  - `<context_save>` section (line 325-355) reinforces the emit contract every 3-5 messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section that teaches the LLM to detect and merge INBOUND project-context envelopes by name attr.
  - No rule that says "the project name to use in `<project-context name=\"...\">` is taken VERBATIM from the prompt's `CURRENT_PROJECT_CONTEXT:` header line; do not re-derive."

  From src/owl/echo_commune.rs::build_current_context_blocks (Plan 01-fixed state):
  - Emits `CURRENT_PROJECT_CONTEXT:\n<body>` in the prompt.
  - Project name resolved via `last_project_name` (Plan 01).
  - But the prompt does NOT explicitly carry the project name STRING as a separate header — it just emits the body. The Psyche LLM must therefore either: (a) trust the prompt's pairing implicitly (i.e. "whatever the prompt's CURRENT_PROJECT_CONTEXT is about, that IS the current project"), OR (b) the prompt needs to be enriched in build_current_context_blocks to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM has an explicit name token to echo back.

  Decision required at execute time (see Task 1 checkpoint): pick approach (a) or (b). Plan 04 builds on whichever choice is made here.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Project-name token surfacing in the haiku prompt</decision>
    <context>
      psyche.md currently teaches the LLM to emit `<project-context>` (no name attr) and have the parser route by cwd_project. Plan 01 made the resolver authoritative for project name (`last_project_name`), but the haiku prompt does NOT currently carry the resolved name as a string token the LLM can echo. Two options for closing this gap:

      Option A (LLM-implicit pairing) — `psyche.md` instructs the LLM: "whatever project the prompt's CURRENT_PROJECT_CONTEXT block is about, emit `<project-context>` (no name attr) and the receiver writes to `projects/<that name>/<self_id>.md`. The LLM does NOT need to know the name string; the writer (Rust side) re-resolves it via `last_project_name` at write time."

      Option B (explicit name token) — Modify `build_current_context_blocks` (Plan 01 file, already touched in Wave 1) to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM can read the name and emit `<project-context name=\"<X>\">...</project-context>`. Parser/writer reads the `name=` attr from the envelope.

      Implication for Plan 04 (Defect A wrapper routing): Option B lets the wrapper short-circuit inbound `<project-context name=\"X\">` directly to disk without consulting any other source. Option A means the wrapper must consult tracked-info too — same code path as the LLM-side write helper.
    </context>
    <options>
      <option id="option-a">
        <name>LLM-implicit pairing (no name attr; writer re-resolves)</name>
        <pros>Smaller psyche.md diff; no envelope-shape change; existing parse_two_slice / route_two_slice already work this way; Phase 25 Plans 01-04 invariants preserved.</pros>
        <cons>Plan 04 wrapper routing also needs to re-resolve project name from info.json (duplicates Plan 01's resolver call at a third site); inbound envelopes from cross-project agents could be misrouted if the receiver's `last_project_name` is stale.</cons>
      </option>
      <option id="option-b">
        <name>Explicit name token in `<project-context name=\"X\">`</name>
        <pros>Self-describing envelope; wrapper can route inbound to `projects/X/<self_id>.md` directly without filesystem lookups; survives cross-project messaging; matches the producer-side `<EVENT project=\"X\">` attribute convention.</pros>
        <cons>Requires changes to `build_current_context_blocks` (already touched in Plan 01 — adds a small wording diff), `parse_two_slice` (parse the `name=` attr), `route_two_slice` (consume the attr in the envelope rather than relying on a separate argument), and psyche.md emit rules; risk of breaking existing in-flight `<project-context>` envelopes if any are queued.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. The remaining tasks in this plan follow the chosen option.</resume-signal>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md aligned with the chosen option</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 in full (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. When an inbound payload contains `<project-context...>...</project-context>` (Option B) or `<project-context>...</project-context>` (Option A), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>` (Option A — receiver re-resolves name from prompt) or `<project-context name=\"X\">...</project-context>` (Option B — echo the name attr verbatim).

      3. Inbound envelope merging is ALWAYS active — you do not need a CURRENT_LIVE_CONTEXT / CURRENT_PROJECT_CONTEXT prompt block to merge inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. (Option B only) Name-token rule: the value of `name=` in `<project-context name=\"X\">` MUST come from one of two sources and ONLY these two:
         (a) The prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header line (when present — set by build_current_context_blocks).
         (b) The `name=` attr of an inbound `<project-context name=\"X\">` envelope you are echoing back.
         NEVER derive the name from cwd, file paths, or any other source.

      Update the existing `<output_envelope>` rule at psyche.md line 322 to remove the `<cwd_project>` phrasing and replace with `<project_name resolved by `build_current_context_blocks`>` (Option A) or `<name attr value>` (Option B). Cite the Plan 01 fix in a comment so future editors see the link.

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions and align with the chosen option.

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching, aligned with the chosen option from Task 1.

      Keep total prompt growth under +60 lines (psyche.md is context-window-critical for every Psyche session).
    </action>
    <verify>
      <automated>grep -c "project-context" psyche.md ; grep -c "live-context" psyche.md ; wc -l psyche.md</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-3 (and rule 4 if Option B).
      - `<output_envelope>` line 322 wording updated.
      - `<context_save>` section aligned with chosen option.
      - psyche.md total line count increase ≤60 lines.
      - No new pulse/timed-pulse/tool-permission rules introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches the human / Self-side agent how to compose a commune/signoff body. Confirm or update:

      1. The producer-side teaching matches the chosen option from Task 1:
         - Option A: producer emits `<project-context>...</project-context>` (no name attr) when the cwd is inside a tracked project.
         - Option B: producer emits `<project-context name=\"<project>\">...</project-context>` and the name comes from the same source as build_current_context_blocks (tracked-agents info.json `last_project_name`, or the local repo basename if the producer is the human-side Self agent — document this fallback explicitly).

      2. Both files reference the same routing destination: `projects/<name>/<self_id>.md` (the Phase 25 schema). Old references to `projects/<self_id>.md` (per-agent flat layout — the user's mistaken expectation from the debug doc) must be corrected.

      3. Both files cross-reference psyche.md's absorption rules with a one-line pointer ("Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules.").

      Do NOT introduce new commune subcommands, new flags, or new file-drop conventions. This is strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically a thinner wrapper; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1; no binary rebuild needed for skill-side changes. Mark the SUMMARY with this distinction.
    </action>
    <verify>
      <automated>grep -n "project-context" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md ; grep -n "projects/<.*>/.*\.md\|projects/.*/.*\.md" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<name>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - Producer-side teaching matches the chosen option (A or B) for the name attr convention.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: cargo build + DEPLOY + binary handoff + smoke check artifact creation</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe.

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire (.more-done window).
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (run_in_background: true).
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes.

      Step 5 — Confirm artifact creation on disk:
      ```
      ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify checkpoint — the LLM may need additional prompt tightening, or the route_two_slice writer may have an unrelated bug surfaced by this fix.
    </action>
    <verify>
      <automated>ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\" 2>$null ; test-path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped to v1.11.5 (or current+1).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 02 together: corrected project-name resolver in `build_current_context_blocks` (Plan 01) AND `psyche.md` absorption rules teaching the Psyche LLM to detect, merge, and re-emit `<project-context>` envelopes (this plan).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists.
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando, wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears (proves the absorption→merge→re-emit→route_two_slice round trip works end-to-end).
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=\"X\">` (Option B only) | mitigate | Add a validation rule in psyche.md: name must match `^[a-zA-Z0-9_.-]+$` and the receiver writer (route_two_slice) MUST sanitize before joining into a filesystem path. Existing Phase 25 D-10/D-11 attribute validation is the wider mitigation; this plan reuses that contract. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via `<project-context name>` echo | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14) and existing route_two_slice destination. No new surface. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild of existing crates. Cargo.lock not modified except by deterministic rebuild. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds.
  - DEPLOY.ps1 runs successfully; plugin version bumped.
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output.
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally (no regression).
  - Optional round-trip test: marker text from a deliberate inbound commune appears in the project context file after one full echo_commune cycle.
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-3 (+ rule 4 if Option B).
  - commune.md + signoff/SKILL.md aligned with chosen option.
  - Binary deployed; in-flight perches handed off.
  - `projects/<project>/<self_id>.md` artifact materializes on disk for at least one agent (todlando) after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Chosen option (A or B) and rationale.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt showing both envelopes in haiku output.
  - Artifact verification: `ls` of `projects/` directory contents.
  - Operator-verify response.
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/owl/poll.rs
    - src/owl/send.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract: which boundaries encode, which decode, and in what order."
      - "When a commune EVENT envelope arrives at the Psyche LLM, the body is either CONSISTENTLY encoded (outer tags AND inner envelope tags both escaped, `<br>` as literal text inside an encoded body) or CONSISTENTLY decoded (outer EVENT tag readable AND inner `<live-context>` / `<project-context>` tags readable, with newlines split on `<br>`). NEVER the mixed state observed at 16:58:53 (outer raw, inner encoded)."
      - "If the producer side has been over-encoding inner envelopes that already arrive escape-free from the LLM, that double-encode is identified and removed at the source — NOT compensated for by an extra decode pass downstream."
      - "If a wrapper relay path is adding a second encode pass between Self listener and Psyche delivery, that pass is identified and removed — relays are pure pass-throughs unless explicitly composing a new EVENT envelope."
      - "A new written-down decode contract lives in a single doc surface (e.g. `src/owl/poll.rs` module doc OR a sibling .md in `.planning/phases/25.3-*/`) so future agents have ONE source of truth."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites"
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs (or new tests/event_encoding_chain.rs)"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding"
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; must round-trip with at most ONE encode + ONE decode pair"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract must hold across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract. Either:
  - The producer is doing a SECOND encode pass on inner envelope tags only (e.g. the human-side commune body already contains `<live-context>` literal tags from the user, and the producer encodes them once for transport, while the OUTER `<EVENT>` is composed fresh post-encode so it stays raw). Then poll-side decode runs once and the result is the mixed state. The fix is: do NOT encode user-supplied inner envelope tags at producer time — they ARE the structure the consumer needs to parse.
  - The wrapper relay is doing an extra encode pass between Self listener and Psyche LLM stdin (introducing `&lt;` only on body content that already contained `<` and `>` post-spool-decode). The fix is: make the wrapper relay a pure pass-through.
  - The Self listener is decoding `<br>` to newline but NOT decoding `&lt;` (partial decode). The fix is: complete the decode chain in poll-side path.

  Today the project-wide encode/decode contract is split across multiple modules (`src/owl/poll.rs::event_body_escape`, `event_attr_escape`, `parse_event_from_attr`; `src/owl/echo_commune.rs::compose_echo_commune_payload`; `src/live/wrapper/mod.rs` relay; `src/live/commune.rs`) with no single document defining the full traversal. The documented contract (per the ROADMAP entry) is: `<br>` split first, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;` LAST. But there is no central enforcement and no end-to-end test asserting symmetry.

  This plan:
  1. Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff).
  2. Identifies the asymmetric site (the source of the leak).
  3. Fixes at the source — single encode at producer, single decode at consumer; no double-anything anywhere.
  4. Writes down the contract in a single doc surface as the authoritative reference.
  5. Adds an end-to-end test that captures a fabricated commune body, runs it through the full pipeline, and asserts the body delivered to the Psyche-bound stdin position has no mixed encoding state.

  Output:
  - Trace report (in SUMMARY) of where each encode and decode happens.
  - Single source-of-truth doc block (in `poll.rs` or a new `encoding.rs` module).
  - Source-of-truth fix at the asymmetric site.
  - End-to-end test guarding against regression.
  - Binary rebuild.

  Note: Plan 04 (Defect A) DEPENDS on this plan's outcome — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Plan 04 starts AFTER this plan settles the contract.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse of event_attr_escape on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites (rg results):
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose: attrs + body escape.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition: attr + body escape.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs attr escape (5-attr stamp).

  Decode call sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also a from-attr decoder; same as above? confirm).
  - (?) — body decode? There is NO body decode site documented yet — the body is intended to reach the Psyche LLM ENCODED, and the LLM is supposed to read `&lt;live-context&gt;` AS the literal tag-name? OR the LLM is supposed to read `<live-context>` (decoded)? psyche.md inspection (Plan 02) says the LLM emits DECODED tags but it's silent on what it CONSUMES. THIS IS THE CONTRACT GAP TO RESOLVE.

  The asymmetry the user observed (outer `<EVENT>` and `<br>` raw, inner `&lt;live-context&gt;` encoded) means SOMEONE is decoding the outer but not the inner — or someone is encoding the inner a second time after a first decode. Trace must identify which.

  Current `<br>` handling:
  - Encode side: `event_body_escape` converts `\n` → `<br>` LAST.
  - Decode side: no explicit `<br>` → `\n` decode found in grep. If consumer reads `<br>` literally, that's a BUG (or a documented "Psyche LLM understands `<br>` as newline" contract — which would explain why `<br>` is left as `<br>` while `&lt;` would need decoding for `<live-context>` tag detection).
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no file edits in this task)</files>
    <action>
      Run the following greps (run_in_background: true for any long ones; collate results inline):
      ```
      rg -n "event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr" src/
      rg -n "&amp;|&lt;|&gt;|&quot;|<br>" src/ --type rust
      rg -n "replace.*&amp;|replace.*&lt;" src/
      rg -n "deliver_body|deliver_body_anonymous|deliver_body_deferred" src/
      ```

      For each emit site (every `format!` or `write!` that constructs an EVENT envelope):
      - Note the file/line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site (every reader of a body that contains EVENT envelope structure):
      - Note the file/line.
      - Note what is decoded (attrs / body / both / neither).
      - Note what is done with the decoded value (LLM stdin / Self prompt inject / written to file).

      Specifically trace ONE commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop, Phase 30).
      2. Self listener (`src/owl/poll.rs`) detects the file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151 — attr escape ONLY, no body escape because the file path is an attr).
      3. Self listener delivers the file_drop EVENT via owl REST to the Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives the file_drop EVENT, parses kind+path, reads the file from disk (raw file contents — no encoding here since this is filesystem read), composes a `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER for body encode behavior.
      5. Wrapper sends the composed envelope to the Psyche LLM stdin via `resume_session_with_exit`.

      For the OUTBOUND echo_commune path (separate trace):
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH trace step record: encoded? decoded? pass-through? If a body crosses a step and the encoding state changes, that step is the suspect.

      Produce a CHAIN TABLE in the SUMMARY of the form:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD: encoded? raw?) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (NOT yet read in context-loading — read now from src/live/wrapper/mod.rs or wherever it lives). This is the prime suspect for asymmetric encoding because the inbound user body contains literal `<live-context>` tags from the human-side commune writer, but the outer `<EVENT>` is composed fresh.

      Identify the asymmetric site and document the root cause in the SUMMARY trace.
    </action>
    <verify>
      <automated>rg -c "compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload" src/ | head -20</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line and a specific reason ("X encodes body, but downstream Y also encodes" OR "X decodes inner tags but not outer" OR similar).
      - Root cause hypothesis stated explicitly and is testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Encoding contract direction — Psyche LLM consumes encoded or decoded inner envelopes?</decision>
    <context>
      The Psyche LLM is supposed to detect `<live-context>` and `<project-context>` tags in inbound payloads (per Plan 02 absorption rules). For that to work, the LLM must SEE the literal `<` and `>` characters — not `&lt;` / `&gt;`. So the consumer-side contract MUST be: at the Psyche stdin write site, all body content is fully decoded (no `&lt;` / `&gt;` / `&quot;` / `&amp;` entities; `<br>` either decoded to `\n` or kept literal — choose one and document).

      The producer-side encode is necessary because outer `<EVENT>` tag boundaries need to be unambiguous in the spool storage / TCP transport layer (otherwise a literal `<EVENT>` inside the body would corrupt the framing).

      So the contract is: encode at producer, decode at the LAST consumer boundary before LLM stdin. The question is WHERE the decode lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single decode site; all upstream layers (spool, TCP, poll, wrapper inner-poll) see encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool stores encoded, TCP carries encoded, but everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay.

      Option C — No decode at all; the Psyche LLM is taught (psyche.md) to handle `&lt;live-context&gt;` literally. Requires psyche.md change AND breaks the absorption rule in Plan 02. Reject.

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` directly on an inbound envelope, the body MUST be decoded first so `parse_two_slice` can find the literal `<live-context>` tag. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether it's already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary</name>
        <pros>Single decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-side encode-once symmetry.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication.</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Fix the asymmetric site identified in Task 1, aligned with the chosen option</name>
    <files>src/owl/echo_commune.rs, src/owl/poll.rs, src/live/wrapper/mod.rs, src/live/commune.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - A new helper `event_body_unescape` (inverse of `event_body_escape`) is added to `src/owl/poll.rs` if it does not already exist. The decode order is: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (matching the documented inverse of the encode order; cite the existing `parse_event_from_attr` precedent at poll.rs:720).
      - The decode is invoked at exactly ONE site (Option A: wrapper LLM-stdin write; Option B: poll site post-receive).
      - All producer-side double-encode bugs (if any) are removed at the producer — NOT compensated for downstream.
      - All wrapper relay paths are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Unit tests cover round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for a fabricated multi-tag body containing `<live-context>nested & content<br>line2</live-context>`.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691). Mirror the documented inverse order from `parse_event_from_attr`:
      ```
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```
      Doc-comment the function citing Defect C fix and the amp-last invariant.

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of:
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` in `src/live/wrapper/mod.rs` is encoding a body that was already encoded by the upstream Self listener), OR
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B), OR
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      The exact fix depends on Task 1's findings. Whichever applies, document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract in a single source-of-truth doc block at the top of `src/owl/poll.rs` (or extract to a new `src/owl/encoding.rs` module if the doc grows large). The block must cover:
      - Where encoding happens (every producer emit site).
      - Where decoding happens (the ONE chosen consumer site).
      - The decode order (`<br>` first, `&amp;` last).
      - What the spool / TCP / wrapper relay see (encoded throughout, OR encoded up to the poll site only).
      - Forbidden combinations (no double-encode; no half-decode).

      Step 4 — Add unit tests in `tests/native_owl.rs` (or `tests/encoding_chain.rs`):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly)

      Step 5 — Add an end-to-end test (new `tests/encoding_chain_end_to_end.rs`):
      - Fabricate a commune body containing both literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>` (the user-side commune writer composes these per Plan 02's producer-side teaching).
      - Run it through the full pipeline using existing test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that would hit Psyche stdin.
      - Assert: the final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND `<br>` is either fully decoded to `\n` (if Option A includes `<br>` decode) or left as literal `<br>` (if the contract preserves it). Assert NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape compose_commune_payload encoding_chain 2>&1 | tail -40</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with the amp-last invariant.
      - The asymmetric site is fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists at the top of `poll.rs` (or in `encoding.rs`).
      - 4 unit tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + smoke verify</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release`.

      Step 2 — Run full test suite: `cargo test --release` — confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests.

      Step 3 — Deploy via DEPLOY.ps1 -Bump patch. STOP before /reload-plugins (per CLAUDE.md).

      Step 4 — After user reload, send a commune to todlando containing inner envelope tags:
      ```
      $OWL deliver todlando <<'EOF'
      <live-context>marker-live-xyz</live-context>
      <project-context>marker-proj-abc</project-context>
      EOF
      ```

      Step 5 — Tail `todlando.log` and inspect the next `[PSYCHE] resume` entry. The body delivered to the Psyche LLM stdin (visible in the wrapper's log capture of the resume input) MUST show:
      - Outer EVENT tag readable (`<EVENT type="commune" ...>`).
      - Inner `<live-context>marker-live-xyz</live-context>` readable (literal `<` and `>`, NOT `&lt;` / `&gt;`).
      - Inner `<project-context>marker-proj-abc</project-context>` readable.
      - `<br>` either readable as literal `<br>` (if the contract preserved it) OR converted to actual newlines (depends on Option A's `<br>` decode choice).

      NO mixed state (outer readable + inner encoded). If mixed, the fix is incomplete — circle back to Task 1's trace.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - Full test suite passes (no regressions).
      - DEPLOY succeeds; plugin version bumped.
      - Smoke trace confirms NO asymmetric encoding state at Psyche stdin boundary.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. Single encode at producer; single decode at the chosen consumer boundary (Option A or B). End-to-end test guards against regression. Single source-of-truth contract doc block lives in `src/owl/poll.rs` (or `encoding.rs`).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `encoding.rs`). Confirm it describes the producer/consumer encode/decode points unambiguously.
      2. Inspect `todlando.log` for the smoke-test commune from Task 4. Confirm the body delivered to Psyche has uniform encoding state (NOT mixed).
      3. Run `cargo test --release` locally. Confirm encoding chain tests pass.
      4. Optional: examine the producer-side and consumer-side trace table in 25.3-03-SUMMARY.md — confirm only ONE encode and ONE decode in the full chain.
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean and the smoke trace shows uniform encoding, or describe the remaining asymmetry.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects (per chosen Option). |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. This plan PRESERVES that protection — the decode happens AFTER framing has done its job (at the LLM-stdin boundary). Tested via the round-trip and end-to-end tests. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode) | mitigate | Amp-last invariant documented in `event_body_unescape` doc comment + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly; existing EVENT-PART chunker (Phase 27 — `<br>` body cap not yet known; flag for follow-up if a body-size cap doesn't already exist). Add a TODO in the SUMMARY if no cap exists. |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place.
  - 4 unit tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Smoke trace on todlando shows uniform encoding state.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root cause statement.
  - Chosen option (A or B) and rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim).
  - Test names + pass evidence.
  - Smoke trace excerpt.
  - Plan 04 handoff note: "When `process_file_drop` receives a file_drop EVENT, the body is in state X (encoded / decoded / mixed-by-design)" — this answer is the foundation for Plan 04 Task 1.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 25.3-03
  files_modified:
    - src/live/wrapper/mod.rs
    - psyche.md
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<name>/<self_id>.md` on disk EITHER (Option 1) directly via wrapper-side `route_two_slice` call before LLM handoff, OR (Option 2) round-trip-via-LLM with psyche.md re-emit-on-next-fire hardening — the user choice is recorded and implemented exactly."
      - "The wrapper's `process_file_drop` is the gatekeeper for both options; no other inbound envelope route exists."
      - "Whichever option ships, an end-to-end test demonstrates: inbound payload with marker text → after one full cycle → marker text appears in `projects/<name>/<self_id>.md`."
      - "If Option 1 ships, the wrapper consumes a body in the state defined by Plan 03 (encoded or decoded — explicit, not assumed) and calls `route_two_slice` with correctly-stated inputs."
      - "If Option 2 ships, psyche.md `<absorption>` rules from Plan 02 are extended with a hard-guarantee re-emit clause: 'every fire that sees any inbound `<project-context>` MUST include a `<project-context>` in its output envelope, even if the body is unchanged'."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop with inbound route_two_slice call (Option 1) OR unchanged + comment block citing Option 2 rationale (Option 2)"
        contains: "route_two_slice"
      - path: "psyche.md"
        provides: "If Option 2: hardened re-emit-on-next-fire rule under <absorption>"
        contains: "re-emit"
      - path: "tests/native_owl.rs (or new wrapper_inbound_routing.rs)"
        provides: "End-to-end inbound envelope persistence test"
        contains: "inbound_project_context"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/common/tracked.rs::route_two_slice (Option 1) OR psyche.md absorption→re-emit (Option 2)"
        via: "single decision point for inbound envelope persistence"
        pattern: "route_two_slice|re-emit"
  ---

  <objective>
  Decide and implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md`. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — it never calls `route_two_slice` on the inbound envelope.

  The user's stated expectation ("envelope arrives → write to `projects/.../todlando.md`") is not implemented anywhere. Phase 25 Plan 04 wired `route_two_slice` on the EMIT side (echo_commune haiku fire) and on the signoff side, but NOT on the inbound receive path. The architecture relies on round-trip-via-LLM: the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk. When upstream defects (B2, D, C) corrupt that round-trip, inbound `<project-context>` is silently lost.

  Two architecturally valid options. The user chooses; this plan encodes both with full implementation details and the choice resolves at Task 1 checkpoint.

  This plan depends on Plan 03: the encoding contract MUST be settled before the wrapper can safely route an inbound body, because `route_two_slice` consumes the body via `parse_two_slice` which expects literal `<live-context>` / `<project-context>` tags (decoded form).

  Output:
  - User decision recorded at Task 1 checkpoint.
  - Implementation of the chosen option.
  - End-to-end test demonstrating inbound persistence (with both options, the success criterion is the same: marker text in `projects/<name>/<self_id>.md` after one full cycle).
  - Binary rebuild + deploy + smoke verification.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for both options. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind, or `compose_init_signoff_payload` for "signoff" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318) — Psyche LLM consumes it.
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315) — wrapper teardown path.
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags. EXPECTS DECODED INPUT.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - `<context_save>` section says emit every 3-5 messages.

  Plan 03 outcome (assumed available at execute time):
  - Encoding contract document specifies the state of the body at the wrapper file_drop boundary. Read 25.3-03-SUMMARY.md "Plan 04 handoff note" for the exact answer.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper-side inbound envelope routing strategy</decision>
    <context>
      The user must choose between two architecturally valid approaches. Both deliver the same observable success (marker text in `projects/<name>/<self_id>.md` after one cycle); they differ in WHEN and HOW.

      Option 1 — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call `route_two_slice(self_id, body, "commune-inbound", short)`.
      - The body is the file_drop file contents (raw filesystem read; encoding state per Plan 03's documented chain at this site).
      - If Plan 03 says the body is in encoded form here (e.g. Option B from Plan 03 decoded at poll instead), we may need to decode first. If Plan 03 says decoded, we route directly.
      - Pros: Synchronous persistence; inbound envelope hits disk immediately; no dependency on the LLM round-trip working correctly; survives a Psyche LLM that drops the envelope.
      - Cons: Loses the "Psyche merges in-memory" semantic — the LLM still receives the envelope and may re-emit a stale version that overwrites the just-routed body on the next echo_commune fire. Mitigated because `build_current_context_blocks` already reads `projects/<name>/<self_id>.md` at prompt-build time (per Plan 01 — verified at line 411-420 of the patched code), so the LLM sees the just-routed content as CURRENT_PROJECT_CONTEXT and merges from there. Still, double-write through the LLM round-trip could overwrite with stale content if the LLM's output omits the project-context section.

      Option 2 — Confirm round-trip-via-LLM is intended; harden psyche.md re-emit.
      - Do NOT modify `process_file_drop`.
      - Extend psyche.md `<absorption>` section (built in Plan 02) with a hard-guarantee re-emit clause: "Every fire that sees any inbound `<project-context>` envelope MUST include a `<project-context>` in its output context-save, even if the body is unchanged."
      - Optionally: add a Rust-side guard in echo_commune that detects "we have CURRENT_PROJECT_CONTEXT but the haiku's output lacks `<project-context>`" as a regression signal (log warning; consider future enforcement).
      - Pros: Preserves the merge-in-LLM semantic; no double-write race; smaller code surface.
      - Cons: Relies on the LLM following instructions; if the LLM occasionally drops the envelope (as todlando did per the original debug evidence), data is lost. Defends only with prompt engineering, not invariant.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_two_slice before LLM handoff</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune.</pros>
        <cons>Double-write through round-trip; relies on build_current_context_blocks reading from disk (already true per Plan 01); needs Plan 03's decoded-body answer to know whether to decode first.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM + psyche.md re-emit hardening</name>
        <pros>Smaller code surface; preserves merge-in-LLM architectural intent; no race.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss; harder to test.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 or option-2. Subsequent tasks implement exactly the chosen option.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (Option 1 path): Wire route_two_slice in process_file_drop with Plan 03 decode awareness</name>
    <files>src/live/wrapper/mod.rs</files>
    <behavior>
      - When `process_file_drop("commune", path)` is invoked with a file containing `<project-context>marker-abc</project-context>` (decoded form, as filesystem read returns), `route_two_slice` is called with the body and the source string `"commune-inbound"`.
      - `projects/<name>/<self_id>.md` exists on disk after the call returns (assuming Plan 01's `last_project_name` resolves correctly for the target self_id).
      - The existing LLM-handoff path is preserved — the body STILL flows into `resume_session_with_exit` so the Psyche LLM can update its in-memory context. Route is a SIDE EFFECT, not a replacement.
      - If `route_two_slice` returns Err (e.g. project name unresolvable for this self_id), log a structured `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)` and proceed to LLM handoff. NEVER abort the file_drop processing on route failure.
      - "signoff" kind is NOT changed (signoff already routes via the dedicated signoff path; this plan only addresses inbound commune file_drops).
    </behavior>
    <action>
      SKIP this task if Option 2 was chosen at Task 1. Proceed to Task 3 (Option 2 path).

      Read `src/live/wrapper/mod.rs` lines 1231-1350 in full (process_file_drop is the entire scope).

      Confirm Plan 03's handoff note: at the point of `let body = std::fs::read_to_string(path)?` (line 1247), is the body in DECODED form? Filesystem reads are always raw bytes; the producer-side writer (Phase 30 `/spt:commune` skill) writes user prose with literal `<live-context>` / `<project-context>` tags — so YES, decoded form at this site. Plan 03's contract should confirm.

      Modify `process_file_drop`'s "commune" arm:

      Before the `let envelope = match kind { ... }` block (currently at line 1278), insert:

      ```
      // 25.3-04 Option 1: route inbound commune body to two-slice before LLM
      // handoff. route_two_slice consumes decoded form (parse_two_slice expects
      // literal <live-context> / <project-context> tags); filesystem read yields
      // decoded body verbatim, so no decode pass needed here. Per Plan 03
      // encoding contract, the wrapper file_drop site sees decoded bodies.
      //
      // This is a SIDE EFFECT, not a replacement: the body still flows into
      // resume_session_with_exit below so the Psyche LLM updates its in-memory
      // context. The route_two_slice call hardens against LLM drops (per
      // todlando 2026-05-22 debug). Failures are non-fatal — LLM handoff
      // continues regardless.
      if kind == "commune" {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());
          if let Err(e) = crate::common::tracked::route_two_slice(
              &self.self_id,
              &body,
              "commune-inbound",
              &short,
          ) {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              ));
          } else {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound)",
                  self.self_id
              ));
          }
      }
      ```

      Reuse the "short" derivation pattern from echo_commune.rs:848-853 verbatim (DRY — same short composition rule).

      Add unit tests in the existing wrapper tests block OR a new `tests/wrapper_inbound_routing.rs`:
      - `process_file_drop_commune_routes_two_slice_with_inbound_project_context`
      - `process_file_drop_commune_continues_llm_handoff_when_route_fails`
      - `process_file_drop_signoff_does_not_route_two_slice` (negative — signoff path unchanged)
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` (timing assertion: route call site precedes resume_session_with_exit call site)

      Tests will need to construct a test wrapper instance with mocked LLM handoff (existing test scaffolds in `file_drop_handler_tests` module — extend those).
    </action>
    <verify>
      <automated>cargo test --release process_file_drop_commune_routes_two_slice 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `process_file_drop` "commune" arm calls `route_two_slice` BEFORE composing the envelope and handing off to LLM.
      - Route failures are logged and non-fatal.
      - "signoff" arm unchanged.
      - 4 unit tests pass.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path): Harden psyche.md re-emit guarantee + optional Rust regression detector</name>
    <files>psyche.md, src/owl/echo_commune.rs</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1. Proceed to Task 4.

      Read `psyche.md` `<absorption>` section (added in Plan 02) and `<context_save>` section.

      In the `<absorption>` section, add a new rule (rule N, where N is one past Plan 02's last rule):

      "**Re-emit guarantee:** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged from the prior context-save. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer (route_two_slice) treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Optional Rust-side regression detector (only if scope budget allows in this plan, otherwise defer to a follow-up):

      In `src/owl/echo_commune.rs::run_echo_commune`, after the haiku stdout is captured and before `route_two_slice` is invoked at line 854, add a check:
      - If the haiku prompt included `CURRENT_PROJECT_CONTEXT:` AND the haiku output did NOT contain `<project-context>`, log a structured warning:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (re-emit guarantee violated; see 25.3-04)", self_id);
        ```
      - This is observability, not enforcement. A future plan can decide to ENFORCE (e.g. retry the haiku with a stricter prompt).

      The detection is a simple substring check: `prompt.contains("CURRENT_PROJECT_CONTEXT:")` and `output.contains("<project-context>")`. No new dependencies.
    </action>
    <verify>
      <automated>grep -c "Re-emit guarantee" psyche.md ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - psyche.md `<absorption>` section has the new "Re-emit guarantee" rule.
      - (Optional) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (both options share this)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — DEPLOY.ps1 -Bump patch. STOP before /reload-plugins.

      Step 4 — After user reload, send a deliberate inbound commune to todlando containing a fresh marker:
      ```
      $OWL deliver todlando <<'EOF'
      <project-context>marker-25.3-04-Z (timestamp $(date +%s))</project-context>
      EOF
      ```

      Step 5 — Verification (option-dependent):

      **Option 1 expected:** within seconds (before the next echo_commune fire), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`. The `[FILE-DROP] route_two_slice ok for todlando (commune-inbound)` log line appears in todlando.log.

      **Option 2 expected:** within ONE echo_commune cycle (cadence-driven or .more-done-triggered), the marker text appears in the same file. The wrapper-side route_two_slice log line does NOT appear (path unchanged).

      Step 6 — Inspect the on-disk file content and confirm the marker text round-tripped intact (no encoding artifacts from Plan 03's chain).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md" 2>$null | head -20</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` per the chosen option's expected timing.
      - Log lines match the chosen option's expected behavior.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in order:
      - Plan 01 (B2): project resolver correctness.
      - Plan 02 (D): psyche.md absorption + emission teaching.
      - Plan 03 (C): encoding contract uniformity.
      - Plan 04 (A, this plan): inbound envelope routing decision (Option 1 wrapper-direct OR Option 2 LLM-round-trip-with-re-emit-guarantee).

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist to `projects/<name>/<self_id>.md` on disk, consistently and observably.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to any live agent (todlando is the canonical test target).
      2. Wait the appropriate cycle (Option 1: immediate; Option 2: next echo_commune fire — up to 15 minutes for cadence-driven; sub-minute for .more-done-triggered).
      3. Inspect `projects/<project>/<self_id>.md` — marker text MUST appear.
      4. Inspect todlando.log for the chosen option's expected log line presence/absence.
      5. Send a SECOND `<project-context>` with a different marker — confirm both markers either coexist (LLM merge behavior) or the second overwrites the first (route_two_slice atomic write). Either is acceptable; document which actually happens.
      6. Confirm `live_context.md` continues to update for the same agent (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the inbound envelope persistence works end-to-end per the chosen option, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1 only) | Wrapper-side direct write to `projects/<name>/<self_id>.md`. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | `route_two_slice` consumes the body — Phase 25 D-10/D-11 already mandated input sanitization on the project name. The project name comes from `last_project_name` (resolver, Plan 01) not from the envelope body — body never controls destination path. Plan 02's Option B (if chosen) would have a `name=` attr that DOES need sanitization; that mitigation lives in Plan 02. This plan inherits both. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with the LLM round-trip) | accept | If Option 1 ships and the LLM round-trip later emits a stale `<project-context>` body, the file content reverts. Mitigated because Plan 01's build_current_context_blocks reads the current file content into the haiku prompt — so the LLM sees the just-written content as baseline. Still, a misbehaving LLM could regress; the operator-verify checkpoint surfaces this. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes (atomic write + git commit). Same risk profile as existing echo_commune fires. |
  | T-25.3.04-R1 | Repudiation | LLM-driven re-emit silently drops `<project-context>` (Option 2) | mitigate (partial) | The Rust-side `[ECHO-WARN]` detector (Task 3) logs prompt-output asymmetry. Operator visible via tail of logs. Future enforcement could retry the haiku. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision verbatim.
  - The chosen option's task (Task 2 or Task 3) completes; the other is skipped.
  - Task 4 deploys + smoke-verifies end-to-end.
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded.
  - Chosen option implemented exactly (no inadvertent crossover behavior).
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within the option-appropriate window.
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 or 2) and the user's rationale (verbatim from the Task 1 checkpoint response).
  - Diff summary for the implementation file(s).
  - End-to-end smoke trace.
  - Inspection of `projects/claude_skill_owl/todlando.md` showing the marker text.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 02, 03, 04) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist reliably).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed
  3. **Concerns** — Potential issues, gaps, risks. **MARK EACH CONCERN WITH SEVERITY: HIGH, MEDIUM, or LOW.** HIGH = will cause plan failure or shipping bug; MEDIUM = significant risk requiring mitigation; LOW = nice-to-have or stylistic.
  4. **Suggestions** — Specific improvements
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on: missing edge cases, dependency ordering, scope creep, security, performance, whether plans achieve phase goals. Output in markdown.

preview:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Plans to Review

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  must_haves:
    truths:
      - "When a live agent has `last_project_name` set in tracked-agents info.json, `build_current_context_blocks` resolves the project to that value (NOT `tracked`, NOT psyche_dir basename)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` returned by `derive_current_repo_names()` D-06 fallback is rejected as a false positive and the project-block is omitted instead."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new helper used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via the `build_current_context_blocks` helper (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-first project resolution + `tracked` rejection"
        contains: "last_project_name"
      - path: "tests/native_owl.rs"
        provides: "Regression tests for B2 fix: info.json wins; tracked rejected; missing info.json falls through"
        contains: "build_current_context_blocks"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::tracked_agent_info_path + TrackedAgentInfo.last_project_name"
        via: "typed read of tracked-agents info.json"
        pattern: "tracked_agent_info_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md`. The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) resolves the project name via `derive_current_repo_names()`, which reads `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and route_two_slice writes to `projects/tracked/<self_id>.md` (or — empirically — drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  The same fire pass at line 869 already correctly resolves via `resolve_self_project_stamp(self_id)`, which is supposed to read Self's project from info.json. But that helper reads the PERCH info.json `project` field (line 132–139) — a field that is never populated — and falls back to cwd-stamp. Phase 24.1 D-09 lifted the canonical `last_project_name` field to the TRACKED-AGENTS info.json (`tracked_agent_info_path(self_id)` → `agents/<id>/info.json`, struct `TrackedAgentInfo.last_project_name`). The TODO at echo_commune.rs:108 explicitly flags this: "use `last_project_name` from tracked agents/info.json once available; current v1.8 fallback = psyche_dir basename via stamp()."

  This plan:
  1. Upgrades the project-name resolver to prefer tracked-agents info.json `last_project_name` over the unused perch info.json `project` field.
  2. Wires `build_current_context_blocks` through the corrected resolver (currently it bypasses the helper entirely and goes straight to `derive_current_repo_names()`).
  3. Adds a `"tracked"` rejection guard so the D-06 false positive can never leak through even when info.json resolution fails.
  4. Audits the two other callers of `build_current_context_blocks` (signoff prompt-build path; any pulse/init context assembly site) to confirm they pick up the fix transparently.

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name, so no downstream writer is ever exercised. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/owl/echo_commune.rs` — corrected `build_current_context_blocks` + supporting helper.
  - New unit tests in `tests/native_owl.rs` (or sibling) covering info.json hit / info.json miss / `tracked` rejection.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers the executor needs. Extracted from src/common/owlery.rs + src/common/types.rs. -->
  <!-- The fix wires build_current_context_blocks to consume TrackedAgentInfo.last_project_name. -->

  From src/common/types.rs (line 72-90):
  ```rust
  /// in this exact sequence. `last_project_name` is `Option<String>` so
  pub struct TrackedAgentInfo {
      pub agent_id: String,
      pub last_started: Option<String>,
      pub last_machine_name: Option<String>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub last_project_name: Option<String>,
      pub machine_history: Vec<MachineHistoryEntry>,
      pub project_history: Vec<ProjectHistoryEntry>,
  }
  ```

  From src/common/owlery.rs (line 901, 1054-1075):
  ```rust
  pub fn tracked_agent_info_path(agent_id: &str) -> PathBuf;
  // Pure path resolver: $SPT_HOME/psyches/tracked/agents/<agent_id>/info.json
  // Does NOT create the directory; safe to call on missing-agent paths.

  pub struct TrackedAgentInfoSummary {
      pub last_project_name: Option<String>,
      // ... other Phase 24.1 fields
  }
  // Lightweight read-only projection.
  ```

  From src/common/owlery.rs (line 1253):
  ```rust
  pub fn read_tracked_agent_info_summary(perch_id: &str) -> Option<TrackedAgentInfoSummary>;
  // Tracked-first read; returns None if tracked info.json missing/malformed.
  ```

  From src/common/owlery.rs (`project_worktree_path` — used at echo_commune.rs:415):
  ```rust
  pub fn project_worktree_path(project_name: &str) -> PathBuf;
  // $SPT_HOME/psyches/tracked/projects/<project_name>/
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written; the (a) branch always returns None; falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — totally bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today, but Phase 24.1 means it CAN get the right name if the helper is upgraded.
  </interfaces>

  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Upgrade project-name resolution to read tracked-agents info.json last_project_name</name>
    <files>src/owl/echo_commune.rs, tests/native_owl.rs</files>
    <behavior>
      - When `tracked_agent_info_path(self_id)` exists AND its TrackedAgentInfo has `last_project_name = Some("claude_skill_owl")`, the resolver returns `"claude_skill_owl"`.
      - When `tracked_agent_info_path(self_id)` exists but `last_project_name` is None / empty / missing, the resolver returns None (does NOT fall back to cwd-stamp at the resolver level — caller decides).
      - When `tracked_agent_info_path(self_id)` does not exist (path missing), the resolver returns None.
      - When `last_project_name = Some("tracked")` (defensive — should not happen but guard regardless), the resolver returns None (reject the D-06 false positive at the source).
      - The existing `resolve_self_project_stamp(self_id)` (line 110) is refactored to consult the new helper FIRST, then fall back to the legacy perch info.json `project` read (kept for back-compat with any populated field), then fall back to `crate::common::git::stamp()` cwd basename.
      - The TODO comment at line 108 is removed (it is being fulfilled here).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 80-140 in full.

      Add a new helper after `read_self_project_from_info` (around line 140):
      ```
      fn read_tracked_last_project_name(self_id: &str) -> Option<String>
      ```
      that calls `crate::common::owlery::read_tracked_agent_info_summary(self_id)` and returns `summary.last_project_name` after filtering out empty strings AND the literal `"tracked"` (case-sensitive — `tracked` is the known D-06 false positive; any agent that genuinely has a project named `tracked` is the user's problem). Document the `"tracked"` rejection with an inline comment citing `.planning/debug/todlando-project-context-not-persisted.md` Defect B2 and the D-06 false positive origin.

      Refactor `resolve_self_project_stamp` (line 110-127) to consult the new helper FIRST:
      1. Try `read_tracked_last_project_name(self_id)` — if Some, use that name; build stamp via `crate::common::git::stamp()` and overwrite `s.project = name`.
      2. Else try `read_self_project_from_info(self_id)` (existing legacy perch field — kept for forward compat; if a future phase populates it, it still works).
      3. Else return plain `crate::common::git::stamp()` (existing fallback; will be `tracked` from psyche_dir).

      Remove the TODO comment at lines 108-109 (it is being fulfilled).

      DO NOT modify `build_current_context_blocks` in this task — that is Task 2. Keep this task scoped to the resolver upgrade so the existing outbound-stamp consumer at line 869 picks up the fix transparently and we can test the helper in isolation.

      Add unit tests in `tests/native_owl.rs` (or create a sibling `tests/echo_commune_resolver.rs` if scope-isolation is cleaner — choose based on existing test-file conventions; if `tests/native_owl.rs` has resolver-shaped tests already, extend it):
      - `read_tracked_last_project_name_returns_name_when_info_json_has_last_project_name`
      - `read_tracked_last_project_name_returns_none_when_info_json_missing`
      - `read_tracked_last_project_name_returns_none_when_last_project_name_field_absent`
      - `read_tracked_last_project_name_rejects_literal_tracked_false_positive`
      - `resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field`

      Note: `read_tracked_last_project_name` is private to `echo_commune.rs`; tests use the `#[cfg(test)] mod tests` block at the bottom of the file (already exists per existing tests at line 1736+). Place new helper tests there alongside the existing escape-helper tests. The `tests/native_owl.rs` integration-style tests are for end-to-end stamping behavior — keep those for resolver-via-stamp assertions if needed.
    </action>
    <verify>
      <automated>cargo test --release --lib resolve_self_project_stamp_prefers_tracked_info_over_legacy_perch_field read_tracked_last_project_name 2>&1 | tail -30</automated>
    </verify>
    <done>
      - New helper `read_tracked_last_project_name` exists and is unit-tested.
      - `resolve_self_project_stamp` consults the new helper FIRST.
      - TODO at line 108-109 removed.
      - 5 unit tests pass.
      - `cargo build --release` succeeds (binary rebuild required for downstream task; this build is the witness).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks to use the corrected resolver + reject tracked false positive</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked with cwd=psyche_dir, where tracked-agents info.json for `todlando` has `last_project_name = Some("claude_skill_owl")`, produces a prompt containing `CURRENT_PROJECT_CONTEXT:` followed by the content of `projects/claude_skill_owl/todlando.md` (or the first-commune literal if the file is absent).
      - The same call when info.json has `last_project_name = None` AND `derive_current_repo_names()` returns `["tracked"]` produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block (the `"tracked"` D-06 fallback is rejected; haiku correctly sees "no tracked project" and emits only `<live-context>`).
      - The same call when info.json is missing AND `derive_current_repo_names()` returns `["my_real_repo"]` produces a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `my_real_repo` (the cwd fallback path still works for non-haiku callers — e.g. signoff prompt-build invoked from a real project cwd).
      - Outbound EVENT stamping at line 869 (resolve_self_project_stamp) and the new inner-block resolution at line 411 BOTH read the same project name in the same fire pass. No asymmetry.
    </behavior>
    <action>
      Modify `src/owl/echo_commune.rs::build_current_context_blocks` (lines 399-423).

      Replace the `derive_current_repo_names().first()` call at line 411 with a layered resolver:

      Step 1 — Try tracked-agents info.json first via the new helper from Task 1 (`read_tracked_last_project_name(self_id)`). If Some(name), use that.

      Step 2 — Else fall back to `owlery::derive_current_repo_names().first()` (the existing cwd-derived path), BUT add a guard: if the returned name is the literal `"tracked"`, treat it as None (reject the D-06 false positive per Defect B2). This guard is belt-and-braces — Task 1 already filters in the helper, but this second guard ensures the cwd-only path also cannot leak `"tracked"`.

      Step 3 — If no name resolved, omit the project block entirely (existing behavior preserved — emits `CURRENT_LIVE_CONTEXT` only, haiku correctly emits only `<live-context>` per psyche.md rule 2).

      Preserve the existing first-commune-literal fallback for the file-read step (when project name resolves but the `projects/<name>/<self_id>.md` file is absent → emit block with `(none — first commune in project)`).

      Update the doc comment block on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (tracked-info → cwd → omit).
      - Keep the existing Plan 04 Task 4 DRY note about the signoff prompt builder reusing this helper.
      - Note that outbound EVENT stamping at run_echo_commune line 869 and inner-block resolution here MUST stay in sync; both consume `read_tracked_last_project_name` first.

      Add unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_tracked_info_last_project_name`
      - `build_current_context_blocks_rejects_tracked_d06_false_positive`
      - `build_current_context_blocks_omits_block_when_no_project_resolved`
      - `build_current_context_blocks_uses_first_commune_literal_when_project_file_missing`

      Tests will need to set up a temp SPT_HOME with a tracked agent info.json. Existing tests in this file already do this pattern (search for `tracked_agent_info_path` usage in test code); follow the established harness.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `read_tracked_last_project_name` first.
      - `"tracked"` literal is explicitly rejected at the cwd-fallback step.
      - 4 unit tests pass.
      - Doc comment updated with Defect B2 attribution + sync invariant with line 869.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Audit shared callers + rebuild binary + manual smoke-test stamping symmetry</name>
    <files>src/live/signoff.rs, src/owl/echo_commune.rs</files>
    <behavior>
      - `crate::live::signoff::compose_init_signoff_payload` (the Plan 04 DRY reuse site at echo_commune.rs:397) calls `build_current_context_blocks` and therefore picks up the fix transparently — no code change needed there, but confirm via grep + trace.
      - No other caller of `build_current_context_blocks` exists OR each additional caller is documented and verified to handle the new "may omit project block" behavior correctly.
      - No other caller of `derive_current_repo_names()` in the haiku/signoff prompt-build path relies on the `"tracked"` fallback as intended behavior. (Confirmed by grep + reading each call site; document findings inline in the SUMMARY.)
      - After `cargo build --release` + binary handoff, a manual smoke trace shows: outbound EVENT envelope `project=` attr (from resolve_self_project_stamp at line 869) matches the inner `CURRENT_PROJECT_CONTEXT:` project derived in build_current_context_blocks. No asymmetry.
    </behavior>
    <action>
      Step 1 — Grep audit:
      ```
      rg -n "build_current_context_blocks" src/
      rg -n "derive_current_repo_names" src/
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder (signoff.rs callers per the Plan 04 DRY note) — transparently fixed.
      - Anything else — investigate and either confirm safe or extend the fix.

      Step 2 — Inspect `src/live/signoff.rs::compose_init_signoff_payload`: confirm it actually invokes `build_current_context_blocks(self_id)` (not a divergent copy of the project-resolution logic). If it does, no edit needed. If it has its own resolver, port the Task 2 logic there too (or refactor to call build_current_context_blocks).

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site found in step 1. For each, confirm it is either:
      (a) Not a haiku/signoff prompt builder (so the D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way.
      Document findings in the plan SUMMARY.

      Step 4 — Rebuild the binary:
      ```
      cargo build --release
      ```

      Step 5 — Manual smoke trace (run_in_background: true since active perch):
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` and trigger an echo_commune fire (either wait for cadence or send a deliberate `.more-done` trigger).
      - Locate the next [PSYCHE] commune EVENT in the log.
      - Confirm: the outer EVENT envelope's `project="..."` attr matches whatever the new haiku-prompt build resolved as `CURRENT_PROJECT_CONTEXT` project name (read from haiku stdout or the rendered prompt). They MUST be the same string. Expected: both = `claude_skill_owl` for todlando.

      Step 6 — Verify the artifact destination:
      - After the fire, check `%LOCALAPPDATA%\spt\psyches\tracked\projects\` — `claude_skill_owl/` directory MUST now exist (lazy-created by route_two_slice on first write to `projects/claude_skill_owl/todlando.md`). Note: this verification only succeeds once Defect D is also fixed (Plan 02), because the haiku LLM still needs to be instructed to emit `<project-context>`. With ONLY this plan landed, the haiku gets the right project name but still won't necessarily emit the envelope. Document this dependency in the SUMMARY.
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | tail -10 ; rg -c "build_current_context_blocks|derive_current_repo_names" src/ | head -20</automated>
    </verify>
    <done>
      - Audit of `build_current_context_blocks` callers complete; SUMMARY documents each site and its disposition.
      - Audit of `derive_current_repo_names` callers complete; non-prompt-build sites confirmed safe.
      - `cargo build --release` succeeds.
      - Manual smoke confirms outbound EVENT `project` attr matches inner-block project (live trace logged in SUMMARY).
      - SUMMARY documents the Plan 02 dependency for full `projects/<name>/<self_id>.md` artifact creation.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | tracked-agents info.json read | Filesystem read of a JSON document that another process (Phase 24.1 wrapper bumps) writes |
  | haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `last_project_name` field in tracked-agents info.json | accept | Field is written by SPT itself (Phase 24.1 D-09) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. No security boundary crossed. |
  | T-25.3.01-I1 | Information disclosure | Project name leaked into haiku prompt | accept | Project name is the user's repo basename; same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed tracked-agents info.json crashes the resolver | mitigate | `read_tracked_agent_info_summary` returns Option and swallows JSON parse errors (existing behavior — Phase 24.1 sealed). The new helper inherits Option-return semantics; missing/malformed → None → fall back to cwd path. Resolver MUST NOT panic on any input. |
  | T-25.3.01-T2 | Tampering | `"tracked"` value written into `last_project_name` by an adversarial process to mask the real project | mitigate | The Task 1 helper explicitly rejects the literal `"tracked"` string. This is also the D-06 false positive guard; same code serves both threats. |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `read_tracked_last_project_name` is the new primary source.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback.
     - All three layers are exercised by unit tests.

  2. Grep audit complete:
     - `rg -n "build_current_context_blocks" src/` lists ≤2 call sites (echo_commune fire + signoff prompt build); both transparent.
     - `rg -n "derive_current_repo_names" src/` non-prompt-build sites confirmed unaffected.

  3. Binary smoke trace confirms outbound EVENT `project` attr === inner-block project name (no asymmetry).

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).
  </verification>

  <success_criteria>
  - `build_current_context_blocks("todlando")` returns a prompt with `CURRENT_PROJECT_CONTEXT:` keyed on `claude_skill_owl` (not `tracked`, not omitted) when tracked-agents info.json has `last_project_name = Some("claude_skill_owl")`.
  - No `cargo test` regressions.
  - `cargo build --release` succeeds; binary handoff to running todlando wrapper completes per Phase 18.4/18.5 mechanics.
  - SUMMARY documents the Plan 02 dependency: full `projects/<name>/<self_id>.md` artifact creation requires both B2 fix (this plan) AND D fix (Plan 02) to land.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Resolver layer diagram (tracked-info → legacy-perch → cwd-stamp).
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Smoke-trace log excerpt showing outbound EVENT `project=` attr matching inner-block project.
  - Dependency note: artifact creation at `projects/<name>/<self_id>.md` blocked on Plan 02 (Defect D — psyche.md absorption instruction).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context name=\"<name>\">...</project-context>`, it knows from psyche.md that the body persists to `projects/<name>/<self_id>.md` (NOT only to `live_context.md`)."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The project name token the Psyche LLM uses inside `<project-context name=\"...\">` matches the value resolved by Plan 01's upgraded `build_current_context_blocks` (= tracked-agents info.json `last_project_name`); no drift between producer-side teaching and consumer-side resolution."
      - "`plugin/spt/skills/commune/commune.md` (producer-side human guidance) and `plugin/spt/skills/signoff/SKILL.md` (signoff-side teaching) reference the same `<project-context name=\"...\">` token format taught in psyche.md."
      - "After binary rebuild + handoff, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New project-context detection + persistence instruction."
        contains: "<project-context"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (project name token convention)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM persistence instruction)"
        to: "src/owl/echo_commune.rs::build_current_context_blocks (Plan 01)"
        via: "shared project-name token convention (tracked-info last_project_name)"
        pattern: "CURRENT_PROJECT_CONTEXT"
      - from: "psyche.md absorption rules"
        to: "src/common/tracked.rs::route_two_slice"
        via: "two-slice envelope contract (live-context body → live_context.md; project-context body → projects/<name>/<self_id>.md)"
        pattern: "route_two_slice"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work landed at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms the existing teaching (lines 244-354) does cover both envelopes, but two gaps remain:
  1. **Absorption side gap (the primary fix):** The teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context name=\"X\">...</project-context>`, treat its body as project-X delta and merge it into your in-memory context keyed on project X." The user expectation in the debug doc is that incoming envelopes get persisted (Defect A is the wrapper-side counterpart of this; Defect D is the LLM-side counterpart). For the round-trip-via-LLM architecture to work, the Psyche LLM must DETECT inbound project envelopes, merge them, AND re-emit them on the next context-save.
  2. **Name-token convention gap:** The current emit rule (line 322) instructs the parser to route by `<cwd_project>` — but Plan 01 changed the producer-side resolver to use `last_project_name` from tracked-agents info.json (not cwd). The teaching needs to clarify: the project name is whatever appears in the prompt's `CURRENT_PROJECT_CONTEXT:` header (set by `build_current_context_blocks` which now reads `last_project_name`). The LLM should NOT try to re-derive the project name from cwd or any other source; it uses whatever the prompt provides.

  This plan also cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync at deploy time; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` (root) — strengthened absorption rules + name-token clarification.
  - `commune.md` + `signoff/SKILL.md` — producer-side aligned with psyche.md.
  - Binary rebuild + DEPLOY.ps1 (or in-place sync for skill files).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after Plan 01 + Plan 02 are both live.

  Purpose: Plan 01 unblocks the haiku from seeing the correct project name. Plan 02 unblocks the haiku LLM from actually emitting the `<project-context>` envelope. Without BOTH, the artifact never materializes on disk.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO `CURRENT_PROJECT_CONTEXT` block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — THIS IS THE WORD TO FIX (Plan 01 broke "cwd" semantics; it is now `last_project_name`).
  - `<context_save>` section (line 325-355) reinforces the emit contract every 3-5 messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section that teaches the LLM to detect and merge INBOUND project-context envelopes by name attr.
  - No rule that says "the project name to use in `<project-context name=\"...\">` is taken VERBATIM from the prompt's `CURRENT_PROJECT_CONTEXT:` header line; do not re-derive."

  From src/owl/echo_commune.rs::build_current_context_blocks (Plan 01-fixed state):
  - Emits `CURRENT_PROJECT_CONTEXT:\n<body>` in the prompt.
  - Project name resolved via `last_project_name` (Plan 01).
  - But the prompt does NOT explicitly carry the project name STRING as a separate header — it just emits the body. The Psyche LLM must therefore either: (a) trust the prompt's pairing implicitly (i.e. "whatever the prompt's CURRENT_PROJECT_CONTEXT is about, that IS the current project"), OR (b) the prompt needs to be enriched in build_current_context_blocks to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM has an explicit name token to echo back.

  Decision required at execute time (see Task 1 checkpoint): pick approach (a) or (b). Plan 04 builds on whichever choice is made here.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Project-name token surfacing in the haiku prompt</decision>
    <context>
      psyche.md currently teaches the LLM to emit `<project-context>` (no name attr) and have the parser route by cwd_project. Plan 01 made the resolver authoritative for project name (`last_project_name`), but the haiku prompt does NOT currently carry the resolved name as a string token the LLM can echo. Two options for closing this gap:

      Option A (LLM-implicit pairing) — `psyche.md` instructs the LLM: "whatever project the prompt's CURRENT_PROJECT_CONTEXT block is about, emit `<project-context>` (no name attr) and the receiver writes to `projects/<that name>/<self_id>.md`. The LLM does NOT need to know the name string; the writer (Rust side) re-resolves it via `last_project_name` at write time."

      Option B (explicit name token) — Modify `build_current_context_blocks` (Plan 01 file, already touched in Wave 1) to emit `CURRENT_PROJECT_CONTEXT (name=<X>):\n<body>` so the LLM can read the name and emit `<project-context name=\"<X>\">...</project-context>`. Parser/writer reads the `name=` attr from the envelope.

      Implication for Plan 04 (Defect A wrapper routing): Option B lets the wrapper short-circuit inbound `<project-context name=\"X\">` directly to disk without consulting any other source. Option A means the wrapper must consult tracked-info too — same code path as the LLM-side write helper.
    </context>
    <options>
      <option id="option-a">
        <name>LLM-implicit pairing (no name attr; writer re-resolves)</name>
        <pros>Smaller psyche.md diff; no envelope-shape change; existing parse_two_slice / route_two_slice already work this way; Phase 25 Plans 01-04 invariants preserved.</pros>
        <cons>Plan 04 wrapper routing also needs to re-resolve project name from info.json (duplicates Plan 01's resolver call at a third site); inbound envelopes from cross-project agents could be misrouted if the receiver's `last_project_name` is stale.</cons>
      </option>
      <option id="option-b">
        <name>Explicit name token in `<project-context name=\"X\">`</name>
        <pros>Self-describing envelope; wrapper can route inbound to `projects/X/<self_id>.md` directly without filesystem lookups; survives cross-project messaging; matches the producer-side `<EVENT project=\"X\">` attribute convention.</pros>
        <cons>Requires changes to `build_current_context_blocks` (already touched in Plan 01 — adds a small wording diff), `parse_two_slice` (parse the `name=` attr), `route_two_slice` (consume the attr in the envelope rather than relying on a separate argument), and psyche.md emit rules; risk of breaking existing in-flight `<project-context>` envelopes if any are queued.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. The remaining tasks in this plan follow the chosen option.</resume-signal>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md aligned with the chosen option</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 in full (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. When an inbound payload contains `<project-context...>...</project-context>` (Option B) or `<project-context>...</project-context>` (Option A), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>` (Option A — receiver re-resolves name from prompt) or `<project-context name=\"X\">...</project-context>` (Option B — echo the name attr verbatim).

      3. Inbound envelope merging is ALWAYS active — you do not need a CURRENT_LIVE_CONTEXT / CURRENT_PROJECT_CONTEXT prompt block to merge inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. (Option B only) Name-token rule: the value of `name=` in `<project-context name=\"X\">` MUST come from one of two sources and ONLY these two:
         (a) The prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header line (when present — set by build_current_context_blocks).
         (b) The `name=` attr of an inbound `<project-context name=\"X\">` envelope you are echoing back.
         NEVER derive the name from cwd, file paths, or any other source.

      Update the existing `<output_envelope>` rule at psyche.md line 322 to remove the `<cwd_project>` phrasing and replace with `<project_name resolved by `build_current_context_blocks`>` (Option A) or `<name attr value>` (Option B). Cite the Plan 01 fix in a comment so future editors see the link.

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions and align with the chosen option.

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching, aligned with the chosen option from Task 1.

      Keep total prompt growth under +60 lines (psyche.md is context-window-critical for every Psyche session).
    </action>
    <verify>
      <automated>grep -c "project-context" psyche.md ; grep -c "live-context" psyche.md ; wc -l psyche.md</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-3 (and rule 4 if Option B).
      - `<output_envelope>` line 322 wording updated.
      - `<context_save>` section aligned with chosen option.
      - psyche.md total line count increase ≤60 lines.
      - No new pulse/timed-pulse/tool-permission rules introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches the human / Self-side agent how to compose a commune/signoff body. Confirm or update:

      1. The producer-side teaching matches the chosen option from Task 1:
         - Option A: producer emits `<project-context>...</project-context>` (no name attr) when the cwd is inside a tracked project.
         - Option B: producer emits `<project-context name=\"<project>\">...</project-context>` and the name comes from the same source as build_current_context_blocks (tracked-agents info.json `last_project_name`, or the local repo basename if the producer is the human-side Self agent — document this fallback explicitly).

      2. Both files reference the same routing destination: `projects/<name>/<self_id>.md` (the Phase 25 schema). Old references to `projects/<self_id>.md` (per-agent flat layout — the user's mistaken expectation from the debug doc) must be corrected.

      3. Both files cross-reference psyche.md's absorption rules with a one-line pointer ("Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules.").

      Do NOT introduce new commune subcommands, new flags, or new file-drop conventions. This is strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically a thinner wrapper; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1; no binary rebuild needed for skill-side changes. Mark the SUMMARY with this distinction.
    </action>
    <verify>
      <automated>grep -n "project-context" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md ; grep -n "projects/<.*>/.*\.md\|projects/.*/.*\.md" plugin/spt/skills/commune/commune.md plugin/spt/skills/signoff/SKILL.md</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<name>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - Producer-side teaching matches the chosen option (A or B) for the name attr convention.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: cargo build + DEPLOY + binary handoff + smoke check artifact creation</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe.

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire (.more-done window).
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (run_in_background: true).
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes.

      Step 5 — Confirm artifact creation on disk:
      ```
      ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify checkpoint — the LLM may need additional prompt tightening, or the route_two_slice writer may have an unrelated bug surfaced by this fix.
    </action>
    <verify>
      <automated>ls "$env:LOCALAPPDATA\spt\psyches\tracked\projects\" 2>$null ; test-path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped to v1.11.5 (or current+1).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 02 together: corrected project-name resolver in `build_current_context_blocks` (Plan 01) AND `psyche.md` absorption rules teaching the Psyche LLM to detect, merge, and re-emit `<project-context>` envelopes (this plan).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists.
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando, wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears (proves the absorption→merge→re-emit→route_two_slice round trip works end-to-end).
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=\"X\">` (Option B only) | mitigate | Add a validation rule in psyche.md: name must match `^[a-zA-Z0-9_.-]+$` and the receiver writer (route_two_slice) MUST sanitize before joining into a filesystem path. Existing Phase 25 D-10/D-11 attribute validation is the wider mitigation; this plan reuses that contract. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via `<project-context name>` echo | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14) and existing route_two_slice destination. No new surface. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild of existing crates. Cargo.lock not modified except by deterministic rebuild. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds.
  - DEPLOY.ps1 runs successfully; plugin version bumped.
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output.
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally (no regression).
  - Optional round-trip test: marker text from a deliberate inbound commune appears in the project context file after one full echo_commune cycle.
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-3 (+ rule 4 if Option B).
  - commune.md + signoff/SKILL.md aligned with chosen option.
  - Binary deployed; in-flight perches handed off.
  - `projects/<project>/<self_id>.md` artifact materializes on disk for at least one agent (todlando) after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Chosen option (A or B) and rationale.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt showing both envelopes in haiku output.
  - Artifact verification: `ls` of `projects/` directory contents.
  - Operator-verify response.
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/owl/poll.rs
    - src/owl/send.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract: which boundaries encode, which decode, and in what order."
      - "When a commune EVENT envelope arrives at the Psyche LLM, the body is either CONSISTENTLY encoded (outer tags AND inner envelope tags both escaped, `<br>` as literal text inside an encoded body) or CONSISTENTLY decoded (outer EVENT tag readable AND inner `<live-context>` / `<project-context>` tags readable, with newlines split on `<br>`). NEVER the mixed state observed at 16:58:53 (outer raw, inner encoded)."
      - "If the producer side has been over-encoding inner envelopes that already arrive escape-free from the LLM, that double-encode is identified and removed at the source — NOT compensated for by an extra decode pass downstream."
      - "If a wrapper relay path is adding a second encode pass between Self listener and Psyche delivery, that pass is identified and removed — relays are pure pass-throughs unless explicitly composing a new EVENT envelope."
      - "A new written-down decode contract lives in a single doc surface (e.g. `src/owl/poll.rs` module doc OR a sibling .md in `.planning/phases/25.3-*/`) so future agents have ONE source of truth."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites"
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs (or new tests/event_encoding_chain.rs)"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding"
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; must round-trip with at most ONE encode + ONE decode pair"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract must hold across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract. Either:
  - The producer is doing a SECOND encode pass on inner envelope tags only (e.g. the human-side commune body already contains `<live-context>` literal tags from the user, and the producer encodes them once for transport, while the OUTER `<EVENT>` is composed fresh post-encode so it stays raw). Then poll-side decode runs once and the result is the mixed state. The fix is: do NOT encode user-supplied inner envelope tags at producer time — they ARE the structure the consumer needs to parse.
  - The wrapper relay is doing an extra encode pass between Self listener and Psyche LLM stdin (introducing `&lt;` only on body content that already contained `<` and `>` post-spool-decode). The fix is: make the wrapper relay a pure pass-through.
  - The Self listener is decoding `<br>` to newline but NOT decoding `&lt;` (partial decode). The fix is: complete the decode chain in poll-side path.

  Today the project-wide encode/decode contract is split across multiple modules (`src/owl/poll.rs::event_body_escape`, `event_attr_escape`, `parse_event_from_attr`; `src/owl/echo_commune.rs::compose_echo_commune_payload`; `src/live/wrapper/mod.rs` relay; `src/live/commune.rs`) with no single document defining the full traversal. The documented contract (per the ROADMAP entry) is: `<br>` split first, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;` LAST. But there is no central enforcement and no end-to-end test asserting symmetry.

  This plan:
  1. Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff).
  2. Identifies the asymmetric site (the source of the leak).
  3. Fixes at the source — single encode at producer, single decode at consumer; no double-anything anywhere.
  4. Writes down the contract in a single doc surface as the authoritative reference.
  5. Adds an end-to-end test that captures a fabricated commune body, runs it through the full pipeline, and asserts the body delivered to the Psyche-bound stdin position has no mixed encoding state.

  Output:
  - Trace report (in SUMMARY) of where each encode and decode happens.
  - Single source-of-truth doc block (in `poll.rs` or a new `encoding.rs` module).
  - Source-of-truth fix at the asymmetric site.
  - End-to-end test guarding against regression.
  - Binary rebuild.

  Note: Plan 04 (Defect A) DEPENDS on this plan's outcome — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Plan 04 starts AFTER this plan settles the contract.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse of event_attr_escape on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites (rg results):
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose: attrs + body escape.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition: attr + body escape.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs attr escape (5-attr stamp).

  Decode call sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also a from-attr decoder; same as above? confirm).
  - (?) — body decode? There is NO body decode site documented yet — the body is intended to reach the Psyche LLM ENCODED, and the LLM is supposed to read `&lt;live-context&gt;` AS the literal tag-name? OR the LLM is supposed to read `<live-context>` (decoded)? psyche.md inspection (Plan 02) says the LLM emits DECODED tags but it's silent on what it CONSUMES. THIS IS THE CONTRACT GAP TO RESOLVE.

  The asymmetry the user observed (outer `<EVENT>` and `<br>` raw, inner `&lt;live-context&gt;` encoded) means SOMEONE is decoding the outer but not the inner — or someone is encoding the inner a second time after a first decode. Trace must identify which.

  Current `<br>` handling:
  - Encode side: `event_body_escape` converts `\n` → `<br>` LAST.
  - Decode side: no explicit `<br>` → `\n` decode found in grep. If consumer reads `<br>` literally, that's a BUG (or a documented "Psyche LLM understands `<br>` as newline" contract — which would explain why `<br>` is left as `<br>` while `&lt;` would need decoding for `<live-context>` tag detection).
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no file edits in this task)</files>
    <action>
      Run the following greps (run_in_background: true for any long ones; collate results inline):
      ```
      rg -n "event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr" src/
      rg -n "&amp;|&lt;|&gt;|&quot;|<br>" src/ --type rust
      rg -n "replace.*&amp;|replace.*&lt;" src/
      rg -n "deliver_body|deliver_body_anonymous|deliver_body_deferred" src/
      ```

      For each emit site (every `format!` or `write!` that constructs an EVENT envelope):
      - Note the file/line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site (every reader of a body that contains EVENT envelope structure):
      - Note the file/line.
      - Note what is decoded (attrs / body / both / neither).
      - Note what is done with the decoded value (LLM stdin / Self prompt inject / written to file).

      Specifically trace ONE commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop, Phase 30).
      2. Self listener (`src/owl/poll.rs`) detects the file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151 — attr escape ONLY, no body escape because the file path is an attr).
      3. Self listener delivers the file_drop EVENT via owl REST to the Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives the file_drop EVENT, parses kind+path, reads the file from disk (raw file contents — no encoding here since this is filesystem read), composes a `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER for body encode behavior.
      5. Wrapper sends the composed envelope to the Psyche LLM stdin via `resume_session_with_exit`.

      For the OUTBOUND echo_commune path (separate trace):
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH trace step record: encoded? decoded? pass-through? If a body crosses a step and the encoding state changes, that step is the suspect.

      Produce a CHAIN TABLE in the SUMMARY of the form:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD: encoded? raw?) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (NOT yet read in context-loading — read now from src/live/wrapper/mod.rs or wherever it lives). This is the prime suspect for asymmetric encoding because the inbound user body contains literal `<live-context>` tags from the human-side commune writer, but the outer `<EVENT>` is composed fresh.

      Identify the asymmetric site and document the root cause in the SUMMARY trace.
    </action>
    <verify>
      <automated>rg -c "compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload" src/ | head -20</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line and a specific reason ("X encodes body, but downstream Y also encodes" OR "X decodes inner tags but not outer" OR similar).
      - Root cause hypothesis stated explicitly and is testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Encoding contract direction — Psyche LLM consumes encoded or decoded inner envelopes?</decision>
    <context>
      The Psyche LLM is supposed to detect `<live-context>` and `<project-context>` tags in inbound payloads (per Plan 02 absorption rules). For that to work, the LLM must SEE the literal `<` and `>` characters — not `&lt;` / `&gt;`. So the consumer-side contract MUST be: at the Psyche stdin write site, all body content is fully decoded (no `&lt;` / `&gt;` / `&quot;` / `&amp;` entities; `<br>` either decoded to `\n` or kept literal — choose one and document).

      The producer-side encode is necessary because outer `<EVENT>` tag boundaries need to be unambiguous in the spool storage / TCP transport layer (otherwise a literal `<EVENT>` inside the body would corrupt the framing).

      So the contract is: encode at producer, decode at the LAST consumer boundary before LLM stdin. The question is WHERE the decode lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single decode site; all upstream layers (spool, TCP, poll, wrapper inner-poll) see encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool stores encoded, TCP carries encoded, but everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay.

      Option C — No decode at all; the Psyche LLM is taught (psyche.md) to handle `&lt;live-context&gt;` literally. Requires psyche.md change AND breaks the absorption rule in Plan 02. Reject.

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` directly on an inbound envelope, the body MUST be decoded first so `parse_two_slice` can find the literal `<live-context>` tag. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether it's already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary</name>
        <pros>Single decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-side encode-once symmetry.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication.</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Fix the asymmetric site identified in Task 1, aligned with the chosen option</name>
    <files>src/owl/echo_commune.rs, src/owl/poll.rs, src/live/wrapper/mod.rs, src/live/commune.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - A new helper `event_body_unescape` (inverse of `event_body_escape`) is added to `src/owl/poll.rs` if it does not already exist. The decode order is: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (matching the documented inverse of the encode order; cite the existing `parse_event_from_attr` precedent at poll.rs:720).
      - The decode is invoked at exactly ONE site (Option A: wrapper LLM-stdin write; Option B: poll site post-receive).
      - All producer-side double-encode bugs (if any) are removed at the producer — NOT compensated for downstream.
      - All wrapper relay paths are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Unit tests cover round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for a fabricated multi-tag body containing `<live-context>nested & content<br>line2</live-context>`.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691). Mirror the documented inverse order from `parse_event_from_attr`:
      ```
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```
      Doc-comment the function citing Defect C fix and the amp-last invariant.

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of:
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` in `src/live/wrapper/mod.rs` is encoding a body that was already encoded by the upstream Self listener), OR
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B), OR
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      The exact fix depends on Task 1's findings. Whichever applies, document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract in a single source-of-truth doc block at the top of `src/owl/poll.rs` (or extract to a new `src/owl/encoding.rs` module if the doc grows large). The block must cover:
      - Where encoding happens (every producer emit site).
      - Where decoding happens (the ONE chosen consumer site).
      - The decode order (`<br>` first, `&amp;` last).
      - What the spool / TCP / wrapper relay see (encoded throughout, OR encoded up to the poll site only).
      - Forbidden combinations (no double-encode; no half-decode).

      Step 4 — Add unit tests in `tests/native_owl.rs` (or `tests/encoding_chain.rs`):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly)

      Step 5 — Add an end-to-end test (new `tests/encoding_chain_end_to_end.rs`):
      - Fabricate a commune body containing both literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>` (the user-side commune writer composes these per Plan 02's producer-side teaching).
      - Run it through the full pipeline using existing test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that would hit Psyche stdin.
      - Assert: the final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND `<br>` is either fully decoded to `\n` (if Option A includes `<br>` decode) or left as literal `<br>` (if the contract preserves it). Assert NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape compose_commune_payload encoding_chain 2>&1 | tail -40</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with the amp-last invariant.
      - The asymmetric site is fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists at the top of `poll.rs` (or in `encoding.rs`).
      - 4 unit tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + smoke verify</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo build --release`.

      Step 2 — Run full test suite: `cargo test --release` — confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests.

      Step 3 — Deploy via DEPLOY.ps1 -Bump patch. STOP before /reload-plugins (per CLAUDE.md).

      Step 4 — After user reload, send a commune to todlando containing inner envelope tags:
      ```
      $OWL deliver todlando <<'EOF'
      <live-context>marker-live-xyz</live-context>
      <project-context>marker-proj-abc</project-context>
      EOF
      ```

      Step 5 — Tail `todlando.log` and inspect the next `[PSYCHE] resume` entry. The body delivered to the Psyche LLM stdin (visible in the wrapper's log capture of the resume input) MUST show:
      - Outer EVENT tag readable (`<EVENT type="commune" ...>`).
      - Inner `<live-context>marker-live-xyz</live-context>` readable (literal `<` and `>`, NOT `&lt;` / `&gt;`).
      - Inner `<project-context>marker-proj-abc</project-context>` readable.
      - `<br>` either readable as literal `<br>` (if the contract preserved it) OR converted to actual newlines (depends on Option A's `<br>` decode choice).

      NO mixed state (outer readable + inner encoded). If mixed, the fix is incomplete — circle back to Task 1's trace.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - Full test suite passes (no regressions).
      - DEPLOY succeeds; plugin version bumped.
      - Smoke trace confirms NO asymmetric encoding state at Psyche stdin boundary.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. Single encode at producer; single decode at the chosen consumer boundary (Option A or B). End-to-end test guards against regression. Single source-of-truth contract doc block lives in `src/owl/poll.rs` (or `encoding.rs`).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `encoding.rs`). Confirm it describes the producer/consumer encode/decode points unambiguously.
      2. Inspect `todlando.log` for the smoke-test commune from Task 4. Confirm the body delivered to Psyche has uniform encoding state (NOT mixed).
      3. Run `cargo test --release` locally. Confirm encoding chain tests pass.
      4. Optional: examine the producer-side and consumer-side trace table in 25.3-03-SUMMARY.md — confirm only ONE encode and ONE decode in the full chain.
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean and the smoke trace shows uniform encoding, or describe the remaining asymmetry.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects (per chosen Option). |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. This plan PRESERVES that protection — the decode happens AFTER framing has done its job (at the LLM-stdin boundary). Tested via the round-trip and end-to-end tests. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode) | mitigate | Amp-last invariant documented in `event_body_unescape` doc comment + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly; existing EVENT-PART chunker (Phase 27 — `<br>` body cap not yet known; flag for follow-up if a body-size cap doesn't already exist). Add a TODO in the SUMMARY if no cap exists. |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place.
  - 4 unit tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Smoke trace on todlando shows uniform encoding state.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root cause statement.
  - Chosen option (A or B) and rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim).
  - Test names + pass evidence.
  - Smoke trace excerpt.
  - Plan 04 handoff note: "When `process_file_drop` receives a file_drop EVENT, the body is in state X (encoded / decoded / mixed-by-design)" — this answer is the foundation for Plan 04 Task 1.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 25.3-03
  files_modified:
    - src/live/wrapper/mod.rs
    - psyche.md
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<name>/<self_id>.md` on disk EITHER (Option 1) directly via wrapper-side `route_two_slice` call before LLM handoff, OR (Option 2) round-trip-via-LLM with psyche.md re-emit-on-next-fire hardening — the user choice is recorded and implemented exactly."
      - "The wrapper's `process_file_drop` is the gatekeeper for both options; no other inbound envelope route exists."
      - "Whichever option ships, an end-to-end test demonstrates: inbound payload with marker text → after one full cycle → marker text appears in `projects/<name>/<self_id>.md`."
      - "If Option 1 ships, the wrapper consumes a body in the state defined by Plan 03 (encoded or decoded — explicit, not assumed) and calls `route_two_slice` with correctly-stated inputs."
      - "If Option 2 ships, psyche.md `<absorption>` rules from Plan 02 are extended with a hard-guarantee re-emit clause: 'every fire that sees any inbound `<project-context>` MUST include a `<project-context>` in its output envelope, even if the body is unchanged'."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop with inbound route_two_slice call (Option 1) OR unchanged + comment block citing Option 2 rationale (Option 2)"
        contains: "route_two_slice"
      - path: "psyche.md"
        provides: "If Option 2: hardened re-emit-on-next-fire rule under <absorption>"
        contains: "re-emit"
      - path: "tests/native_owl.rs (or new wrapper_inbound_routing.rs)"
        provides: "End-to-end inbound envelope persistence test"
        contains: "inbound_project_context"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/common/tracked.rs::route_two_slice (Option 1) OR psyche.md absorption→re-emit (Option 2)"
        via: "single decision point for inbound envelope persistence"
        pattern: "route_two_slice|re-emit"
  ---

  <objective>
  Decide and implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md`. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — it never calls `route_two_slice` on the inbound envelope.

  The user's stated expectation ("envelope arrives → write to `projects/.../todlando.md`") is not implemented anywhere. Phase 25 Plan 04 wired `route_two_slice` on the EMIT side (echo_commune haiku fire) and on the signoff side, but NOT on the inbound receive path. The architecture relies on round-trip-via-LLM: the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk. When upstream defects (B2, D, C) corrupt that round-trip, inbound `<project-context>` is silently lost.

  Two architecturally valid options. The user chooses; this plan encodes both with full implementation details and the choice resolves at Task 1 checkpoint.

  This plan depends on Plan 03: the encoding contract MUST be settled before the wrapper can safely route an inbound body, because `route_two_slice` consumes the body via `parse_two_slice` which expects literal `<live-context>` / `<project-context>` tags (decoded form).

  Output:
  - User decision recorded at Task 1 checkpoint.
  - Implementation of the chosen option.
  - End-to-end test demonstrating inbound persistence (with both options, the success criterion is the same: marker text in `projects/<name>/<self_id>.md` after one full cycle).
  - Binary rebuild + deploy + smoke verification.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for both options. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind, or `compose_init_signoff_payload` for "signoff" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318) — Psyche LLM consumes it.
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315) — wrapper teardown path.
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags. EXPECTS DECODED INPUT.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - `<context_save>` section says emit every 3-5 messages.

  Plan 03 outcome (assumed available at execute time):
  - Encoding contract document specifies the state of the body at the wrapper file_drop boundary. Read 25.3-03-SUMMARY.md "Plan 04 handoff note" for the exact answer.
  </interfaces>

  </context>

  <tasks>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper-side inbound envelope routing strategy</decision>
    <context>
      The user must choose between two architecturally valid approaches. Both deliver the same observable success (marker text in `projects/<name>/<self_id>.md` after one cycle); they differ in WHEN and HOW.

      Option 1 — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call `route_two_slice(self_id, body, "commune-inbound", short)`.
      - The body is the file_drop file contents (raw filesystem read; encoding state per Plan 03's documented chain at this site).
      - If Plan 03 says the body is in encoded form here (e.g. Option B from Plan 03 decoded at poll instead), we may need to decode first. If Plan 03 says decoded, we route directly.
      - Pros: Synchronous persistence; inbound envelope hits disk immediately; no dependency on the LLM round-trip working correctly; survives a Psyche LLM that drops the envelope.
      - Cons: Loses the "Psyche merges in-memory" semantic — the LLM still receives the envelope and may re-emit a stale version that overwrites the just-routed body on the next echo_commune fire. Mitigated because `build_current_context_blocks` already reads `projects/<name>/<self_id>.md` at prompt-build time (per Plan 01 — verified at line 411-420 of the patched code), so the LLM sees the just-routed content as CURRENT_PROJECT_CONTEXT and merges from there. Still, double-write through the LLM round-trip could overwrite with stale content if the LLM's output omits the project-context section.

      Option 2 — Confirm round-trip-via-LLM is intended; harden psyche.md re-emit.
      - Do NOT modify `process_file_drop`.
      - Extend psyche.md `<absorption>` section (built in Plan 02) with a hard-guarantee re-emit clause: "Every fire that sees any inbound `<project-context>` envelope MUST include a `<project-context>` in its output context-save, even if the body is unchanged."
      - Optionally: add a Rust-side guard in echo_commune that detects "we have CURRENT_PROJECT_CONTEXT but the haiku's output lacks `<project-context>`" as a regression signal (log warning; consider future enforcement).
      - Pros: Preserves the merge-in-LLM semantic; no double-write race; smaller code surface.
      - Cons: Relies on the LLM following instructions; if the LLM occasionally drops the envelope (as todlando did per the original debug evidence), data is lost. Defends only with prompt engineering, not invariant.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_two_slice before LLM handoff</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune.</pros>
        <cons>Double-write through round-trip; relies on build_current_context_blocks reading from disk (already true per Plan 01); needs Plan 03's decoded-body answer to know whether to decode first.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM + psyche.md re-emit hardening</name>
        <pros>Smaller code surface; preserves merge-in-LLM architectural intent; no race.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss; harder to test.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 or option-2. Subsequent tasks implement exactly the chosen option.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (Option 1 path): Wire route_two_slice in process_file_drop with Plan 03 decode awareness</name>
    <files>src/live/wrapper/mod.rs</files>
    <behavior>
      - When `process_file_drop("commune", path)` is invoked with a file containing `<project-context>marker-abc</project-context>` (decoded form, as filesystem read returns), `route_two_slice` is called with the body and the source string `"commune-inbound"`.
      - `projects/<name>/<self_id>.md` exists on disk after the call returns (assuming Plan 01's `last_project_name` resolves correctly for the target self_id).
      - The existing LLM-handoff path is preserved — the body STILL flows into `resume_session_with_exit` so the Psyche LLM can update its in-memory context. Route is a SIDE EFFECT, not a replacement.
      - If `route_two_slice` returns Err (e.g. project name unresolvable for this self_id), log a structured `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)` and proceed to LLM handoff. NEVER abort the file_drop processing on route failure.
      - "signoff" kind is NOT changed (signoff already routes via the dedicated signoff path; this plan only addresses inbound commune file_drops).
    </behavior>
    <action>
      SKIP this task if Option 2 was chosen at Task 1. Proceed to Task 3 (Option 2 path).

      Read `src/live/wrapper/mod.rs` lines 1231-1350 in full (process_file_drop is the entire scope).

      Confirm Plan 03's handoff note: at the point of `let body = std::fs::read_to_string(path)?` (line 1247), is the body in DECODED form? Filesystem reads are always raw bytes; the producer-side writer (Phase 30 `/spt:commune` skill) writes user prose with literal `<live-context>` / `<project-context>` tags — so YES, decoded form at this site. Plan 03's contract should confirm.

      Modify `process_file_drop`'s "commune" arm:

      Before the `let envelope = match kind { ... }` block (currently at line 1278), insert:

      ```
      // 25.3-04 Option 1: route inbound commune body to two-slice before LLM
      // handoff. route_two_slice consumes decoded form (parse_two_slice expects
      // literal <live-context> / <project-context> tags); filesystem read yields
      // decoded body verbatim, so no decode pass needed here. Per Plan 03
      // encoding contract, the wrapper file_drop site sees decoded bodies.
      //
      // This is a SIDE EFFECT, not a replacement: the body still flows into
      // resume_session_with_exit below so the Psyche LLM updates its in-memory
      // context. The route_two_slice call hardens against LLM drops (per
      // todlando 2026-05-22 debug). Failures are non-fatal — LLM handoff
      // continues regardless.
      if kind == "commune" {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());
          if let Err(e) = crate::common::tracked::route_two_slice(
              &self.self_id,
              &body,
              "commune-inbound",
              &short,
          ) {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              ));
          } else {
              self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound)",
                  self.self_id
              ));
          }
      }
      ```

      Reuse the "short" derivation pattern from echo_commune.rs:848-853 verbatim (DRY — same short composition rule).

      Add unit tests in the existing wrapper tests block OR a new `tests/wrapper_inbound_routing.rs`:
      - `process_file_drop_commune_routes_two_slice_with_inbound_project_context`
      - `process_file_drop_commune_continues_llm_handoff_when_route_fails`
      - `process_file_drop_signoff_does_not_route_two_slice` (negative — signoff path unchanged)
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` (timing assertion: route call site precedes resume_session_with_exit call site)

      Tests will need to construct a test wrapper instance with mocked LLM handoff (existing test scaffolds in `file_drop_handler_tests` module — extend those).
    </action>
    <verify>
      <automated>cargo test --release process_file_drop_commune_routes_two_slice 2>&1 | tail -30</automated>
    </verify>
    <done>
      - `process_file_drop` "commune" arm calls `route_two_slice` BEFORE composing the envelope and handing off to LLM.
      - Route failures are logged and non-fatal.
      - "signoff" arm unchanged.
      - 4 unit tests pass.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path): Harden psyche.md re-emit guarantee + optional Rust regression detector</name>
    <files>psyche.md, src/owl/echo_commune.rs</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1. Proceed to Task 4.

      Read `psyche.md` `<absorption>` section (added in Plan 02) and `<context_save>` section.

      In the `<absorption>` section, add a new rule (rule N, where N is one past Plan 02's last rule):

      "**Re-emit guarantee:** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged from the prior context-save. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer (route_two_slice) treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Optional Rust-side regression detector (only if scope budget allows in this plan, otherwise defer to a follow-up):

      In `src/owl/echo_commune.rs::run_echo_commune`, after the haiku stdout is captured and before `route_two_slice` is invoked at line 854, add a check:
      - If the haiku prompt included `CURRENT_PROJECT_CONTEXT:` AND the haiku output did NOT contain `<project-context>`, log a structured warning:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (re-emit guarantee violated; see 25.3-04)", self_id);
        ```
      - This is observability, not enforcement. A future plan can decide to ENFORCE (e.g. retry the haiku with a stricter prompt).

      The detection is a simple substring check: `prompt.contains("CURRENT_PROJECT_CONTEXT:")` and `output.contains("<project-context>")`. No new dependencies.
    </action>
    <verify>
      <automated>grep -c "Re-emit guarantee" psyche.md ; cargo build --release 2>&1 | tail -5</automated>
    </verify>
    <done>
      - psyche.md `<absorption>` section has the new "Re-emit guarantee" rule.
      - (Optional) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (both options share this)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — DEPLOY.ps1 -Bump patch. STOP before /reload-plugins.

      Step 4 — After user reload, send a deliberate inbound commune to todlando containing a fresh marker:
      ```
      $OWL deliver todlando <<'EOF'
      <project-context>marker-25.3-04-Z (timestamp $(date +%s))</project-context>
      EOF
      ```

      Step 5 — Verification (option-dependent):

      **Option 1 expected:** within seconds (before the next echo_commune fire), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`. The `[FILE-DROP] route_two_slice ok for todlando (commune-inbound)` log line appears in todlando.log.

      **Option 2 expected:** within ONE echo_commune cycle (cadence-driven or .more-done-triggered), the marker text appears in the same file. The wrapper-side route_two_slice log line does NOT appear (path unchanged).

      Step 6 — Inspect the on-disk file content and confirm the marker text round-tripped intact (no encoding artifacts from Plan 03's chain).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | tail -5 ; cat "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md" 2>$null | head -20</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` per the chosen option's expected timing.
      - Log lines match the chosen option's expected behavior.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in order:
      - Plan 01 (B2): project resolver correctness.
      - Plan 02 (D): psyche.md absorption + emission teaching.
      - Plan 03 (C): encoding contract uniformity.
      - Plan 04 (A, this plan): inbound envelope routing decision (Option 1 wrapper-direct OR Option 2 LLM-round-trip-with-re-emit-guarantee).

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist to `projects/<name>/<self_id>.md` on disk, consistently and observably.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to any live agent (todlando is the canonical test target).
      2. Wait the appropriate cycle (Option 1: immediate; Option 2: next echo_commune fire — up to 15 minutes for cadence-driven; sub-minute for .more-done-triggered).
      3. Inspect `projects/<project>/<self_id>.md` — marker text MUST appear.
      4. Inspect todlando.log for the chosen option's expected log line presence/absence.
      5. Send a SECOND `<project-context>` with a different marker — confirm both markers either coexist (LLM merge behavior) or the second overwrites the first (route_two_slice atomic write). Either is acceptable; document which actually happens.
      6. Confirm `live_context.md` continues to update for the same agent (no regression on the live slice).
    </how-to-verify>
    <resume-signal>Type "approved" if the inbound envelope persistence works end-to-end per the chosen option, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1 only) | Wrapper-side direct write to `projects/<name>/<self_id>.md`. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | `route_two_slice` consumes the body — Phase 25 D-10/D-11 already mandated input sanitization on the project name. The project name comes from `last_project_name` (resolver, Plan 01) not from the envelope body — body never controls destination path. Plan 02's Option B (if chosen) would have a `name=` attr that DOES need sanitization; that mitigation lives in Plan 02. This plan inherits both. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with the LLM round-trip) | accept | If Option 1 ships and the LLM round-trip later emits a stale `<project-context>` body, the file content reverts. Mitigated because Plan 01's build_current_context_blocks reads the current file content into the haiku prompt — so the LLM sees the just-written content as baseline. Still, a misbehaving LLM could regress; the operator-verify checkpoint surfaces this. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes (atomic write + git commit). Same risk profile as existing echo_commune fires. |
  | T-25.3.04-R1 | Repudiation | LLM-driven re-emit silently drops `<project-context>` (Option 2) | mitigate (partial) | The Rust-side `[ECHO-WARN]` detector (Task 3) logs prompt-output asymmetry. Operator visible via tail of logs. Future enforcement could retry the haiku. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision verbatim.
  - The chosen option's task (Task 2 or Task 3) completes; the other is skipped.
  - Task 4 deploys + smoke-verifies end-to-end.
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded.
  - Chosen option implemented exactly (no inadvertent crossover behavior).
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within the option-appropriate window.
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 or 2) and the user's rationale (verbatim from the Task 1 checkpoint response).
  - Diff summary for the implementation file(s).
  - End-to-end smoke trace.
  - Inspection of `projects/claude_skill_owl/todlando.md` showing the marker text.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 02, 03, 04) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist reliably).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed
  3. **Concerns** — Potential issues, gaps, risks. **MARK EACH CONCERN WITH SEVERITY: HIGH, MEDIUM, or LOW.** HIGH = will cause plan failure or shipping bug; MEDIUM = significant risk requiring mitigation; LOW = nice-to-have or stylistic.
  4. **Suggestions** — Specific improvements
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on: missing edge cases, dependency ordering, scope creep, security, performance, whether plans achieve phase goals. Output in markdown.


id: 019e527d-5868-7161-8ed1-b5a47c0bafbd
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\claude_skill_owl
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: 30951b81f0f6aef5f0d5d00574644ee029fef583
git_branch: main
git_origin_url: https://github.com/SaberMage/claude_skill_owl.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\22\rollout-2026-05-22T18-40-06-019e527d-5868-7161-8ed1-b5a47c0bafbd.jsonl

title:
  # Cross-AI Plan Review Request (Cycle 2 — Replan)

  These plans were revised after a Cycle 1 review surfaced 9 HIGH concerns. Verify whether HIGHs are resolved and whether new issues were introduced.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Prior Review (Cycle 1)

  ---
  phase: 25.3
  reviewers: [codex]
  reviewed_at: 2026-05-22T17:55:00-07:00
  plans_reviewed: [25.3-01-PLAN.md, 25.3-02-PLAN.md, 25.3-03-PLAN.md, 25.3-04-PLAN.md]
  ---

  # Cross-AI Plan Review — Phase 25.3

  ## Codex Review

  ## Summary

  The plans are thoughtful and mostly sequenced around the right defect boundaries: B2 first, D paired with B2, C before A. The strongest part is that they treat this as a contract problem across resolver, prompt, encoding, and persistence layers rather than a single missing write. The main weakness is that several plans still contain unresolved architectural choices, contradictory envelope shapes, and test/deploy steps that may fail on Windows or mutate scope mid-plan. I would not execute these as-is without tightening the contracts first.

  ## 25.3-01 Plan Review

  ### Strengths

  - Correctly isolates B2 as the smallest first fix.
  - Uses `last_project_name` as the canonical project source, which matches Phase 24.1.
  - Includes caller audits for `build_current_context_blocks` and `derive_current_repo_names`.
  - Adds regression coverage for `tracked` false-positive handling.

  ### Concerns

  - **HIGH:** `resolve_self_project_stamp` still falls back to plain cwd `stamp()`, which can still produce `project="tracked"` for outbound EVENT attrs. That preserves the same asymmetry the plan is trying to remove.
  - **MEDIUM:** Rejecting literal `"tracked"` globally can break a real project named `tracked`. Better reject it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback.
  - **MEDIUM:** Tests that mutate `SPT_HOME` may be flaky under parallel `cargo test`. They need serial execution or isolated env handling.
  - **MEDIUM:** The plan says the prompt is keyed on `claude_skill_owl`, but the described prompt format only has `CURRENT_PROJECT_CONTEXT:` with no visible project name. That makes smoke verification ambiguous.
  - **LOW:** `files_modified` lists `tests/native_owl.rs`, but the task says to put most tests in the private module inside `echo_commune.rs`.

  ### Suggestions

  - Filter `"tracked"` in all three resolver layers: tracked info, legacy perch field, and cwd fallback.
  - Add a helper returning `Option<String>` for “safe resolved project name” and make both EVENT stamping and context-block resolution use it.
  - Make env-mutating tests serial.
  - Add a debug/test-only way to verify the resolved project name used for the prompt, not just the body path.

  ### Risk Assessment

  **MEDIUM.** The core fix is sound, but the remaining cwd fallback can still emit the bad project name and preserve asymmetry.

  ## 25.3-02 Plan Review

  ### Strengths

  - Correctly identifies that `psyche.md` must teach absorption, not just emission.
  - Aligns `psyche.md`, `commune.md`, and `signoff/SKILL.md`.
  - Calls out that `psyche.md` is embedded and requires rebuild.

  ### Concerns

  - **HIGH:** Option B expands far beyond listed files. It requires `build_current_context_blocks`, `parse_two_slice`, `route_two_slice`, validation, and tests, but the plan only lists prompt/skill files.
  - **HIGH:** The plan alternates between “Psyche writes `projects/<name>/<self_id>.md`” and “Psyche emits envelopes that Rust routes.” That distinction matters. The LLM should not be taught that it directly writes the file unless it actually has that tool path.
  - **HIGH:** The must-haves require `<project-context name="...">`, but Option A explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved.
  - **MEDIUM:** Deploying this before the encoding contract is fixed may still leave the Psyche seeing `&lt;project-context&gt;`, making the new absorption rules ineffective.
  - **MEDIUM:** LLM behavior is only verified by smoke testing. There should also be static prompt-contract tests or golden prompt checks.

  ### Suggestions

  - Pick Option A or B before finalizing the plan. My recommendation: defer Option B unless you are ready to change parser/writer semantics in this same phase.
  - Reword psyche instructions as: “emit this envelope; SPT routes it to disk.”
  - If Option B is chosen, make it a separate Rust implementation plan, not a prompt-doc task.
  - Move this after Plan 03, or explicitly state that Plan 03’s decoded-at-LLM-boundary contract is assumed.

  ### Risk Assessment

  **HIGH.** The plan’s unresolved envelope-shape decision can cause incompatible implementations across Plans 03 and 04.

  ## 25.3-03 Plan Review

  ### Strengths

  - Correctly treats encoding as a full-chain contract problem.
  - Adds a missing `event_body_unescape` inverse and specifies amp-last order.
  - Requires an end-to-end regression for the observed mixed raw/encoded state.
  - Explicitly hands off the body-state answer to Plan 04.

  ### Concerns

  - **HIGH:** Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope string. Otherwise user body content can become structural-looking `<EVENT>` text in the wrong layer.
  - **HIGH:** `depends_on: []` is risky because this plan depends on the envelope shape chosen in Plan 02. Plan 02 and 03 cannot both make independent assumptions.
  - **MEDIUM:** “Decode at exactly ONE site” is too rigid. Attr decoding already exists, and multiple final consumers may need body decoding at their own boundary. The better invariant is “one body decode per transport traversal.”
  - **MEDIUM:** The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary test.
  - **MEDIUM:** Several commands are Bash-style (`tail`, heredoc) despite the project’s Windows-native constraint and PowerShell shell.
  - **LOW:** A large contract doc in `poll.rs` may age poorly if encoding behavior spans modules. A small `encoding.rs` module would be cleaner if churn is expected.

  ### Suggestions

  - Define the contract around parsed fields: attrs decoded as attrs, body decoded only after body extraction.
  - Add tests for literal `</EVENT>`, literal `<br>`, amp entities, and EVENT-PART split/reassembly.
  - Replace smoke commands with PowerShell-compatible here-strings and `Get-Content -Tail`.
  - Make Plan 03 precede Plan 02, or require Plan 02’s envelope decision as an input.

  ### Risk Assessment

  **MEDIUM-HIGH.** The investigation-first structure is good, but an incorrect decode boundary would create framing or prompt-injection regressions.

  ## 25.3-04 Plan Review

  ### Strengths

  - Correctly waits for Plan 03 before routing inbound bodies.
  - Makes the key architecture choice explicit: deterministic wrapper route vs LLM round-trip.
  - Preserves LLM handoff if direct routing is chosen.
  - Treats route failure as nonfatal, which protects live-agent continuity.

  ### Concerns

  - **HIGH:** Option 1 ignores Plan 02 Option B’s possible `name=` attr. If named project-context envelopes are adopted, routing by receiver `last_project_name` may write cross-project context to the wrong file.
  - **HIGH:** Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body. The plan acknowledges this but does not mitigate it.
  - **HIGH:** Option 2 relies on prompt compliance for persistence, which is the failure mode that triggered this phase. It is weaker than the defect requires unless paired with enforcement or retry.
  - **MEDIUM:** “Route failures are nonfatal” is operationally safe but can hide persistence failure. It needs a clear warning/status surface.
  - **MEDIUM:** Tests require mocking `resume_session_with_exit` and `route_two_slice`, but the plan does not call out the necessary dependency injection or helper extraction.
  - **LOW:** `$OWL deliver todlando <<'EOF'` is not PowerShell-compatible.

  ### Suggestions

  - Prefer Option 1, but add stale-overwrite mitigation: after direct route, ensure the next prompt baseline includes the routed content and warn/error if haiku output omits project context.
  - If Option B from Plan 02 is chosen, route by validated `name=` attr; otherwise route by resolved `last_project_name`.
  - Add a monotonic append/merge or source precedence rule so LLM output cannot erase direct-routed inbound content accidentally.
  - Extract a small testable helper for “route inbound commune body” rather than testing through full wrapper process flow.

  ### Risk Assessment

  **HIGH** unless Option 1 is chosen with overwrite mitigation. Option 2 is too dependent on LLM compliance for a persistence defect.

  ## Cross-Plan Recommendations

  1. Resolve envelope shape first: no-name `<project-context>` vs named `<project-context name="X">`. This decision affects Plans 02, 03, and 04.
  2. Recommended order: Plan 01, Plan 03, Plan 02, Plan 04. Encoding state should be settled before prompt absorption rules and wrapper routing.
  3. Avoid deploying after every partial plan unless you intentionally want intermediate behavior live. A single integrated deploy after 01-03, then final deploy after 04, would reduce churn.
  4. Replace Bash commands with PowerShell equivalents throughout.
  5. Fix cargo filter commands. `cargo test` accepts one test filter; commands like `cargo test --release --lib resolve_self_project_stamp_prefers... read_tracked_last_project_name` are likely invalid.
  6. Add one phase-level invariant test: inbound body with live + project envelopes reaches final persistence with no `tracked` project, no encoded tags, and marker text in the expected project file.

  Overall phase risk: **HIGH as written**, mainly due to unresolved cross-plan contract choices. With the envelope shape chosen upfront, resolver fallback tightened, and decode boundary precisely defined, this drops to **MEDIUM**.

  ## Revised Plans (Cycle 2)

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/common/owlery.rs
    - Cargo.toml
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-2 NOTE — codex review resolution map
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 01 section):
  #   HIGH (Plan 01): "resolve_self_project_stamp still falls back to plain cwd stamp(), which can still
  #     produce project='tracked' for outbound EVENT attrs. That preserves the same asymmetry the plan is
  #     trying to remove." → Task 1 + Task 2 (belt-and-braces 'tracked' filter at ALL THREE resolver layers:
  #     tracked-info, legacy perch field, cwd fallback).
  #   HIGH (locked decision #2 — NEW): "project name must derive from the cwd key in Self's perch info.json
  #     at owlery/<self_id>/info.json … resolve project-name from cwd via shared helper" → Task 0 audit +
  #     Task 1 new `project_name_from_cwd_path(&Path)` helper in owlery.rs + Task 2 wires it.
  #   MEDIUM: "Rejecting literal 'tracked' globally can break a real project named tracked. Better reject
  #     it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback."
  #     → Task 1: context-sensitive rejection (helper only filters when cwd resolves to the tracked psyche
  #     dir OR when the result came from the D-06 fallback branch). Tests cover the real-project-named-tracked
  #     non-regression case.
  #   MEDIUM: "Tests that mutate SPT_HOME may be flaky under parallel cargo test. They need serial execution
  #     or isolated env handling." → Task 1: add `serial_test` to Cargo.toml [dev-dependencies] and annotate
  #     env-mutating tests with `#[serial]`. The plan explicitly calls this out.
  #   MEDIUM: "The plan says the prompt is keyed on claude_skill_owl, but the described prompt format only
  #     has CURRENT_PROJECT_CONTEXT: with no visible project name. That makes smoke verification ambiguous."
  #     → Task 2: emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a test/debug-visible
  #     token, so smoke verification can grep the resolved name. (NB: this is purely a header annotation;
  #     LLM-side teaching in Plan 02 will be free to read or ignore the parenthetical.)
  #   LOW: "files_modified lists tests/native_owl.rs, but the task says to put most tests in the private
  #     module inside echo_commune.rs." → manifest fixed: tests live in the existing `#[cfg(test)] mod tests`
  #     blocks inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081) — no entry added to
  #     `tests/native_owl.rs`. files_modified updated.

  must_haves:
    truths:
      - "When Self's perch info.json `cwd` key resolves (via the new owlery helper) to a path inside `claude_skill_owl/`, `build_current_context_blocks` resolves the project to `claude_skill_owl` (NOT `tracked`, NOT psyche_dir basename, NOT raw process cwd)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` is rejected as a project name AT ALL THREE resolver layers (info.json `cwd`-derived → tracked-agents `last_project_name` → cwd `derive_current_repo_names` D-06 fallback) when the source path is the SPT tracked psyche dir, while real user projects literally named `tracked` continue to work (rejection is path-context-sensitive, not literal-global)."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new resolver used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via `build_current_context_blocks` (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper (cwd-path → project name, mirrors `derive_current_repo_names()` resolution order but takes an explicit Path)."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-cwd-derived project resolution + belt-and-braces `tracked` rejection at all three resolver layers."
        contains: "project_name_from_cwd_path"
      - path: "Cargo.toml"
        provides: "`serial_test` dev-dependency added so env-mutating tests can run serially under parallel `cargo test`."
        contains: "serial_test"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "reads `cwd` key from Self's perch info.json (Phase 26 D5/Q1 field), passes Path to shared resolver"
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "same path: read info.json cwd → resolver. Symmetry guarantee."
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md` per LOCKED DECISIONS:

  1. **Locked decision #2:** project name MUST derive from the `cwd` key in Self's perch `info.json` at `$SPT_HOME/owlery/<self_id>/info.json` (the `InfoJson.cwd` field — Phase 26 D5/Q1, `src/common/types.rs:110`). NOT from `tracked_agent_info.last_project_name` directly; NOT from process-global `std::env::current_dir()`. The cwd-key path is authoritative; downstream helpers resolve the project name from that path via shared logic.

  2. **Codex HIGH (belt-and-braces):** `"tracked"` must be filtered at ALL THREE resolver layers — info.json-cwd-derived, legacy perch field, and the cwd D-06 fallback inside `derive_current_repo_names()` consumers — but ONLY when the source path is the SPT tracked psyche dir, so a real user project literally named `tracked` continues to work.

  The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) currently calls `owlery::derive_current_repo_names()`, which reads process-global `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so the D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and `route_two_slice` writes to `projects/tracked/<self_id>.md` (or drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  Meanwhile the same fire pass at line 869 calls `resolve_self_project_stamp(self_id)` which is SUPPOSED to read Self's project from info.json (lines 110-127) but reads the unused legacy perch `project` field and falls through to the same broken cwd stamp.

  **Investigation needed (Task 0):** confirm whether `project_name_from_cwd_path(&Path)` already exists in `src/common/owlery.rs` under some name. From the grep audit (planner side), no such helper exists — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)` (path-based, but private and not exposed). Task 0 explicitly audits + reports findings before Task 1 creates the helper. If a helper does exist, Task 1 reuses it.

  This plan:
  1. (Task 0) Audits `src/common/owlery.rs` for any existing cwd-path → project-name helper. Investigation only.
  2. (Task 1) Creates `project_name_from_cwd_path(path: &Path) -> Option<String>` in `owlery.rs` mirroring the resolution order of `derive_current_repo_names()` but taking an explicit Path. Refactors `resolve_self_project_stamp` to consume it (read perch info.json `cwd` → call helper). Adds belt-and-braces `"tracked"` rejection that fires ONLY when the source path is the SPT tracked psyche dir.
  3. (Task 2) Rewires `build_current_context_blocks` to consult the corrected resolver (same path as `resolve_self_project_stamp`) and emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a debug/test-visible token.
  4. (Task 3) Audits other callers of `build_current_context_blocks` (signoff prompt-build path) to confirm they pick up the fix transparently. Runs `cargo build --release` as the build-witness; defers smoke verification to Plan 02 (which lands psyche.md absorption + deploys).

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/common/owlery.rs` — new `project_name_from_cwd_path` helper + made `find_git_root_basename` visible (pub(crate)) if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new helper.
  - `Cargo.toml` — `serial_test` dev-dependency added.
  - Unit tests in the existing `#[cfg(test)] mod tests` blocks in `src/owl/echo_commune.rs` and `src/common/owlery.rs`.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers extracted from src/common/owlery.rs + src/common/types.rs. -->

  From src/common/types.rs (lines 104-110, Phase 26 D5/Q1):
  ```rust
  pub struct InfoJson {
      // ... other fields
      /// Phase 26 D5/Q1: working directory the perch was created in.
      /// Used by the no-arg `/spt:live` picker for cross-repo collision detection
      /// (`pick-spec --resolve <name>`). Forward-slash-normalized for cross-platform
      /// stability. `None` on perches created before Phase 26 ships — treated as
      /// "unknown repo" by the owlery scan.
      #[serde(skip_serializing_if = "Option::is_none")]
      pub cwd: Option<String>,
      // ... other fields
  }
  ```

  From src/common/owlery.rs (line 204):
  ```rust
  pub fn info_file(id: &str) -> PathBuf;
  // $SPT_HOME/owlery/<id>/info.json — Self's PERCH info.json (NOT the tracked-agents one).
  // This is the one carrying the `cwd` field per locked decision #2.
  ```

  From src/common/owlery.rs (line 792, currently PRIVATE):
  ```rust
  fn find_git_root_basename(start: &std::path::Path) -> Option<String>;
  // Walks parents from `start` looking for `.git`; returns basename of the containing directory.
  // Already-correct primitive for the cwd→project resolver. Task 1 makes this `pub(crate)` so
  // the new public helper can reuse it without duplicating the walk.
  ```

  From src/common/owlery.rs (line 763):
  ```rust
  pub fn derive_current_repo_names() -> Vec<String>;
  // Process-global cwd resolver. Order: (a) find_git_root_basename(env::current_dir())
  // (b) git remote get-url origin basename (c) D-06 fallback to cwd.file_name().
  // The new helper mirrors (a)+(c) but accepts an explicit Path — same return shape (Option<String>).
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written → always returns None → falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today; will resolve correctly after Task 1.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Audit owlery.rs for any existing cwd-path → project-name helper</name>
    <files>(read-only investigation; no edits)</files>
    <action>
      Run greps:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub fn project_name_from|fn derive_project|fn project_name_for|fn project_for_path' -SimpleMatch
      Select-String -Path src/common/*.rs -Pattern 'fn .*project.*_from_|fn .*project_name' -SimpleMatch
      ```
      Also grep across `src/common/` for any helper that takes `&Path` and returns `Option<String>` describing a project name.

      Document findings inline in the SUMMARY:
      - If a helper exists with matching semantics → Task 1 reuses it (record file:line).
      - If `find_git_root_basename` is the closest thing → Task 1 builds the new public helper around it.
      - If nothing exists → Task 1 creates from scratch.

      Decision rule (no checkpoint required — autonomous): if the audit finds a helper that already accepts &Path and resolves project name with at least the (a)+(c) resolution order from `derive_current_repo_names()`, reuse it verbatim and name it as such in Task 1. Otherwise create `project_name_from_cwd_path` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd' | Measure-Object | Select-Object -ExpandProperty Count</automated>
    </verify>
    <done>
      - Audit complete; helper inventory recorded in SUMMARY.
      - Task 1 has an unambiguous decision: reuse-existing OR create-new.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 1: Create project_name_from_cwd_path helper + refactor resolve_self_project_stamp + add serial_test dep</name>
    <files>src/common/owlery.rs, src/owl/echo_commune.rs, Cargo.toml</files>
    <behavior>
      - `project_name_from_cwd_path(Path::new("C:/Users/decid/Documents/projects/claude_skill_owl/some/sub"))` returns `Some("claude_skill_owl")` (resolves via parent walk to the `.git` ancestor at `claude_skill_owl/`).
      - `project_name_from_cwd_path(Path::new("$SPT_HOME/psyches/tracked"))` returns `None` (path-context-sensitive `tracked` rejection — the path resolves via D-06 fallback to basename `tracked` AND the path equals `crate::common::owlery::psyche_dir()`; reject only in that specific case).
      - `project_name_from_cwd_path(Path::new("/home/user/projects/tracked"))` where `tracked/` IS a real `.git` repo returns `Some("tracked")` (real-project-named-tracked non-regression).
      - `project_name_from_cwd_path(Path::new("/nonexistent/path"))` returns `None` gracefully (no panic).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns a stamp with `project = "claude_skill_owl"` (NOT `tracked`).
      - `resolve_self_project_stamp("todlando")` when info.json is missing OR `cwd` field is None → falls through to legacy perch field → finally falls through to `crate::common::git::stamp()` cwd-based — BUT with the same `tracked` rejection guard wrapped around the final fallback (belt-and-braces).
      - Real-project-named-tracked: if a user genuinely has a repo named `tracked` and their process cwd is inside it, `stamp()` returns `project = "tracked"` AND the post-stamp guard does NOT strip it (because the guard only fires when the resolved path equals psyche_dir, not when the name equals literal `"tracked"`).
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version — check what other plans pin) under `[dev-dependencies]`. Tests in Task 1 + Task 2 that mutate `SPT_HOME` will be annotated with `#[serial]`.

      Step 2 — `src/common/owlery.rs`: make `find_git_root_basename` `pub(crate)` (currently private at line 792). Add a new public helper directly below it (around line 800):

      ```rust
      /// Path-based cwd → project name resolver. Mirrors `derive_current_repo_names()`
      /// (D-03 (a) + (c)) but takes an EXPLICIT `&Path` instead of reading process-global
      /// `std::env::current_dir()`.
      ///
      /// Resolution order:
      ///   (a) Walk parents from `path` looking for `.git`; if found, return that
      ///       directory's file_name() as the local basename. (Uses `find_git_root_basename`.)
      ///   (b) If no `.git` is found, fall back to `path.file_name()` as the basename
      ///       (D-06 fallback equivalent).
      ///   (c) Path-context-sensitive `tracked` rejection (25.3-01 Defect B2): if the
      ///       resolved name is the literal `"tracked"` AND `path` canonicalizes to the
      ///       SPT tracked psyche dir (`psyche_dir()`), return None. Real user projects
      ///       literally named `tracked` are NOT rejected (their resolved path lies
      ///       outside psyche_dir).
      ///
      /// Returns None when `path` has no file_name component (filesystem root).
      pub fn project_name_from_cwd_path(path: &std::path::Path) -> Option<String> {
          let name = find_git_root_basename(path)
              .or_else(|| path.file_name().and_then(|s| s.to_str()).map(String::from))?;
          // 25.3-01 Defect B2: path-context-sensitive `tracked` rejection.
          // Real user projects literally named "tracked" are NOT rejected — only the
          // SPT psyche_dir basename collision is filtered.
          if name == "tracked" && is_within_tracked_psyche_dir(path) {
              return None;
          }
          Some(name)
      }

      /// Helper: returns true iff `path` (after canonicalization, best-effort)
      /// is the SPT tracked psyche dir or a child of it. Used by
      /// `project_name_from_cwd_path` to reject the D-06 false positive without
      /// rejecting real user projects literally named "tracked".
      fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool {
          let psyche = psyche_dir();  // existing helper; returns $SPT_HOME/psyches/tracked
          let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
          let psyche_canon = psyche.canonicalize().unwrap_or(psyche);
          path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)
      }
      ```

      Step 3 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127):

      ```rust
      fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp {
          let mut s = crate::common::git::stamp();

          // 25.3-01 Defect B2 (locked decision #2): resolve project from Self's
          // perch info.json `cwd` key (Phase 26 D5/Q1) via the shared helper.
          // This is the AUTHORITATIVE source. Legacy perch `project` field and
          // process cwd are fallbacks only.
          if let Some(name) = resolve_self_project_name_via_info_cwd(self_id) {
              s.project = name;
              return s;
          }

          // Legacy fallback (preserved for forward compat — if a future phase
          // populates the perch `project` field, this path still works).
          if let Some(p) = read_self_project_from_info(self_id) {
              if !p.is_empty() && p != "tracked" {
                  // Belt-and-braces: filter literal "tracked" here too.
                  // Real-project-named-tracked is acceptable collateral at this
                  // layer because the legacy perch `project` field is never
                  // written by any current code path; if a future phase writes
                  // it, that phase is responsible for the value.
                  s.project = p;
                  return s;
              }
          }

          // Final fallback: cwd-relative stamp. Belt-and-braces filter: if the
          // stamp's project is "tracked" AND we can verify via process cwd that
          // we're inside psyche_dir, leave s.project as a NON-project sentinel.
          // BUT preserve real-project-named-tracked: only filter when the
          // current process cwd is psyche_dir.
          if s.project == "tracked" {
              if let Ok(cwd) = std::env::current_dir() {
                  if crate::common::owlery::project_name_from_cwd_path(&cwd).is_none() {
                      // Helper agrees this is the D-06 false positive; clear it.
                      s.project = String::new();
                  }
              }
          }
          s
      }

      /// 25.3-01 Defect B2 (locked decision #2): primary resolver for Self's
      /// project name. Reads Self's perch info.json (Phase 26 `cwd` field),
      /// passes the path to the shared `project_name_from_cwd_path` resolver.
      fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String> {
          let info_path = crate::common::owlery::info_file(self_id);
          let content = std::fs::read_to_string(&info_path).ok()?;
          let info: crate::common::types::InfoJson =
              serde_json::from_str(&content).ok()?;
          let cwd_str = info.cwd?;
          let cwd_path = std::path::PathBuf::from(&cwd_str);
          crate::common::owlery::project_name_from_cwd_path(&cwd_path)
      }
      ```

      Remove the TODO comment at lines 108-109 (now fulfilled).

      Step 4 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s) inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081 — choose whichever is the resolver-/info-shaped block; if no fit, add to the line-1127 block). Tests in `src/common/owlery.rs` go in that file's existing `mod tests`.

      Mark every test that mutates `SPT_HOME` with `#[serial_test::serial]`.

      Tests:
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — create temp dir with `.git/` marker; assert helper returns the dir's basename.
      - `owlery::tests::project_name_from_cwd_path_falls_back_to_basename_when_no_git` — temp dir without `.git`; assert helper returns the basename.
      - `owlery::tests::project_name_from_cwd_path_rejects_psyche_dir_basename` — set `SPT_HOME` to a temp; pass `psyche_dir()` as path; assert returns None. `#[serial]`.
      - `owlery::tests::project_name_from_cwd_path_accepts_real_project_named_tracked` — temp dir literally named `tracked/.git/`; assert helper returns Some("tracked"). `#[serial]` (sets SPT_HOME to a different temp to ensure psyche_dir does NOT equal the project path).
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write a fake perch info.json with cwd pointing to a temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_falls_through_to_legacy_when_cwd_missing` — info.json without `cwd` field; assert legacy path is consulted.
      - `echo_commune::tests::resolve_self_project_stamp_clears_tracked_at_final_fallback` — info.json missing entirely, process cwd is psyche_dir → assert `stamp.project` is empty (NOT `tracked`). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_preserves_real_project_named_tracked` — info.json missing, process cwd is a temp dir literally named `tracked/.git/` OUTSIDE psyche_dir → assert `stamp.project == "tracked"`. `#[serial]`.

      Build witness: `cargo build --release` at end of task.
    </action>
    <verify>
      <automated>cargo test --release project_name_from_cwd_path 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, pub.
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` reads info.json `cwd` first via `resolve_self_project_name_via_info_cwd`.
      - All three resolver layers (info-cwd, legacy perch field, final cwd-stamp) filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces).
      - Real-project-named-tracked test passes (regression guard).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - 8 unit tests pass; `#[serial]` annotations present on env-mutating ones.
      - `cargo build --release` succeeds.
      - TODO at echo_commune.rs:108-109 removed.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks + emit debug-visible project name token</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked when Self's perch info.json `cwd` resolves to `claude_skill_owl/` produces a prompt containing `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` followed by the content of `projects/claude_skill_owl/todlando.md` (or first-commune literal).
      - Same call when info.json `cwd` is missing AND process cwd resolves to psyche_dir (D-06 returns "tracked") produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block.
      - Same call when info.json `cwd` is missing AND process cwd resolves to a real repo (e.g. `my_real_repo/`) produces a prompt with `CURRENT_PROJECT_CONTEXT (name=my_real_repo):` keyed on `my_real_repo`.
      - Outbound EVENT stamping at line 869 and inner-block resolution at line 411 BOTH consume the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). No asymmetry.
      - The `(name=<X>)` parenthetical is purely a debug/test-visible token — Plan 02 will teach the LLM how to consume it (or to ignore it).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 360-425.

      Replace the project resolution at line 411 with the same layered resolver used by `resolve_self_project_stamp`:

      ```rust
      pub(crate) fn build_current_context_blocks(self_id: &str) -> String {
          let live_path = owlery::agent_worktree_path(self_id).join("live_context.md");
          let live_body = std::fs::read_to_string(&live_path)
              .unwrap_or_else(|_| "(none — first commune)".to_string());

          // 25.3-01 Defect B2 (locked decision #2): authoritative resolver
          // (info.json cwd → project_name_from_cwd_path). Symmetric with
          // resolve_self_project_stamp at line 869.
          let resolved_project: Option<String> = resolve_self_project_name_via_info_cwd(self_id)
              .or_else(|| {
                  // Legacy/process-cwd fallback (kept for callers like
                  // signoff prompt-build that may run from a real project cwd).
                  owlery::derive_current_repo_names().into_iter().next().and_then(|name| {
                      // Belt-and-braces (codex HIGH): filter literal "tracked"
                      // here too, but only when the process cwd resolves to
                      // psyche_dir.
                      if name == "tracked" {
                          let cwd = std::env::current_dir().ok()?;
                          if owlery::project_name_from_cwd_path(&cwd).is_none() {
                              return None;
                          }
                      }
                      Some(name)
                  })
              });

          let project_block = match resolved_project {
              None => String::new(),
              Some(name) => {
                  let proj_path =
                      owlery::project_worktree_path(&name).join(format!("{}.md", self_id));
                  let body = std::fs::read_to_string(&proj_path)
                      .unwrap_or_else(|_| "(none — first commune in project)".to_string());
                  // Codex MEDIUM: debug/test-visible name token in the header.
                  // Plan 02 will teach the LLM how to consume or ignore the
                  // parenthetical.
                  format!("\n\nCURRENT_PROJECT_CONTEXT (name={}):\n{}", name, body)
              }
          };

          format!("CURRENT_LIVE_CONTEXT:\n{}{}", live_body, project_block)
      }
      ```

      Update the doc comment on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (info-cwd → process-cwd-with-tracked-filter → omit).
      - Keep the existing Plan 04 Task 4 DRY note about signoff prompt builder.
      - Add: "Outbound EVENT stamping at run_echo_commune line 869 (`resolve_self_project_stamp`) consumes the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). The header's `(name=<X>)` token is debug/test-visible and is the SAME `X` that appears in the outbound `<EVENT project=\"X\">` attr in the same fire pass — assert this symmetry in tests."

      Unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_info_json_cwd_for_project` — write fake perch info.json with cwd pointing to a temp .git repo; assert prompt contains `CURRENT_PROJECT_CONTEXT (name=<repo_basename>):`. `#[serial]`.
      - `build_current_context_blocks_omits_block_when_info_cwd_resolves_to_psyche_dir` — info.json `cwd = psyche_dir`; assert NO `CURRENT_PROJECT_CONTEXT` substring. `#[serial]`.
      - `build_current_context_blocks_emits_name_token_in_header` — assert the exact substring `CURRENT_PROJECT_CONTEXT (name=` appears when a project is resolved. `#[serial]`.
      - `build_current_context_blocks_first_commune_literal_when_project_file_missing` — info.json resolves project but `projects/<name>/<self_id>.md` is absent; assert body is `(none — first commune in project)`. `#[serial]`.
      - `build_current_context_blocks_and_resolve_self_project_stamp_agree` — same fire-pass invariant: both call sites return the SAME project name string. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `resolve_self_project_name_via_info_cwd` first.
      - Header format is `CURRENT_PROJECT_CONTEXT (name=<X>):` when project resolves.
      - Belt-and-braces `tracked` filter on the process-cwd fallback layer.
      - 5 unit tests pass, all `#[serial]`-annotated.
      - Doc comment updated with Defect B2 attribution + symmetry invariant.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Caller audit + binary rebuild (smoke deferred to Plan 02 deploy)</name>
    <files>(read-only audit; build witness only)</files>
    <action>
      Step 1 — Grep audit (PowerShell):
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'build_current_context_blocks|derive_current_repo_names|resolve_self_project_stamp' -SimpleMatch
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder — confirm it calls `build_current_context_blocks` (transparently fixed) or has its own resolver (port the fix).
      - Outbound EVENT stamping (echo_commune.rs:869) — fixed in Task 1.
      - Anything else — investigate.

      Step 2 — Read `src/live/signoff.rs::compose_init_signoff_payload`. Confirm it invokes `build_current_context_blocks(self_id)` and does NOT have a divergent project-resolution path. If divergent, port Task 2 logic OR refactor to call `build_current_context_blocks`. Document the path taken in SUMMARY.

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site. For each, classify as:
      (a) Not a haiku/signoff prompt builder (D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way, OR
      (c) NEEDS the same fix → flag in SUMMARY for follow-up Plan if any.

      Step 4 — Rebuild the binary:
      ```powershell
      cargo build --release
      ```
      Captures binary witness only. Smoke verification (does the artifact materialize?) is DEFERRED to Plan 02, which lands psyche.md absorption rules and performs the deploy.

      Step 5 — Write a one-paragraph "Plan 02 handoff note" in the SUMMARY stating: "After this plan, the haiku prompt is keyed on the correct project name, but the haiku LLM may still drop the `<project-context>` envelope (Defect D). Full end-to-end artifact materialization requires Plan 02."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Caller audit complete; SUMMARY documents each `build_current_context_blocks` and `derive_current_repo_names` site with disposition.
      - `cargo build --release` succeeds.
      - Plan 02 handoff note written.
      - NO deploy in this plan (per cross-plan recommendation: single integrated deploy after Plans 01+03).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Self's perch info.json read (`InfoJson.cwd` field) | Filesystem read of JSON the SPT runtime itself writes at perch creation time. |
  | Process cwd read (fallback layer) | Process-global state; safe for our own process. |
  | Haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `cwd` field in Self's perch info.json | accept | Field is written by SPT at perch creation (Phase 26 D5/Q1) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. The path is consumed read-only — never executed, never joined into a filesystem write destination by this plan. |
  | T-25.3.01-I1 | Information disclosure | Project name (resolved from cwd path) leaked into haiku prompt | accept | Same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed perch info.json crashes the resolver | mitigate | `resolve_self_project_name_via_info_cwd` returns Option via `.ok()?` chain; JSON parse failures → None → falls through to legacy/cwd layers. No panic path. Tested via `info_json_malformed_falls_through` (add if missing). |
  | T-25.3.01-T2 | Tampering | `cwd` field set to a path containing a poison basename (e.g. `..`, `/`, or shell metachars) | mitigate | `project_name_from_cwd_path` uses `Path::file_name()` (returns last normalized component) and `find_git_root_basename` (walks parents); both are pure string/path manipulations — no shell evaluation, no exec. The returned name flows ONLY into `project_worktree_path(&name)` which already joins under `tracked_root()/projects/` with no path traversal escape. |
  | T-25.3.01-T3 | Tampering | Real-project-named-tracked confusion (user has legitimate `tracked` repo) | mitigate | Path-context-sensitive rejection: filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit test `project_name_from_cwd_path_accepts_real_project_named_tracked` guards this. |
  | T-25.3.01-SC | Tampering (supply chain) | `serial_test` crate (new dev-dep) | mitigate | `serial_test` is a widely-used crate (millions of downloads on crates.io). RESEARCH.md package audit not strictly required for dev-only deps, but: verify via `https://crates.io/crates/serial_test` before adding. Pin to a major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW) is the perch-info-driven entry point.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback, with `tracked` filter.
     - All four layers are exercised by unit tests.

  2. Grep audit complete: both `build_current_context_blocks` and `derive_current_repo_names` callers documented.

  3. Binary build witness: `cargo build --release` succeeds.

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).

  5. `serial_test` dev-dep added; env-mutating tests annotated `#[serial]`.
  </verification>

  <success_criteria>
  - `project_name_from_cwd_path(path)` resolves the correct project name when called with Self's perch `cwd` path.
  - `resolve_self_project_stamp("todlando")` returns `project="claude_skill_owl"` when info.json `cwd` is set correctly.
  - `build_current_context_blocks("todlando")` emits `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` (debug-visible token).
  - Belt-and-braces `tracked` filter at all three resolver layers; real-project-named-tracked non-regression preserved.
  - `cargo test --release` passes including 13 new tests; no regressions.
  - `cargo build --release` succeeds.
  - NO deploy from this plan (deferred to Plan 02).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Task 0 audit findings (existing helper or none).
  - Resolver layer diagram (info-cwd → legacy-perch → cwd-stamp), each with its `tracked` filter point.
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note.
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 3
  depends_on:
    - 25.3-01
    - 25.3-03
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 3 (was wave 2), AFTER 03. Absorption rules
  # can only be written authoritatively once we know the body-state at LLM-stdin (Plan 03 contract).
  #
  # This plan addresses the following codex HIGH/MEDIUM concerns from 25.3-REVIEWS.md (Plan 02 section):
  #   HIGH (locked decision #1): "The must-haves require `<project-context name='...'>`, but Option A
  #     explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved."
  #     → RESOLVED. Envelope shape is LOCKED to Option A: no-name `<project-context>`. NO `name=` attr
  #     anywhere in this plan. All occurrences updated. Producer/consumer envelope shape contract is
  #     unchanged from pre-25.3.
  #   HIGH: "Option B expands far beyond listed files. It requires build_current_context_blocks,
  #     parse_two_slice, route_two_slice, validation, and tests, but the plan only lists prompt/skill files."
  #     → RESOLVED by Option A lock. parse_two_slice / route_two_slice are NOT modified. build_current_context_blocks
  #     is changed by Plan 01 (header annotation only, not envelope contract). Files in this plan are strictly
  #     psyche.md + skill files.
  #   HIGH: "The plan alternates between 'Psyche writes projects/<name>/<self_id>.md' and 'Psyche emits
  #     envelopes that Rust routes.' That distinction matters. The LLM should not be taught that it directly
  #     writes the file unless it actually has that tool path." → Task 2 reworded throughout: "emit this
  #     envelope; SPT routes it to disk." Psyche's job = emit. SPT's job = route. No path-writing teaching.
  #   HIGH (resolved by reorder): "depends on Plan 03's encoding contract for body-state at LLM-stdin
  #     boundary." → `depends_on: [25.3-01, 25.3-03]`. Task 0 reads Plan 03 SUMMARY to lift the body-state
  #     answer into psyche.md.
  #   MEDIUM: "Deploying this before the encoding contract is fixed may still leave the Psyche seeing
  #     `&lt;project-context&gt;`, making the new absorption rules ineffective." → RESOLVED by wave order.
  #     This plan deploys ONLY AFTER 03 lands; Plan 03's encoding fix ships in the same deploy.
  #   MEDIUM: "LLM behavior is only verified by smoke testing. There should also be static prompt-contract
  #     tests or golden prompt checks." → Task 2 Step 4 adds a golden prompt check: snapshot the `<absorption>`
  #     section content; test verifies psyche.md contains the canonical block (line count + key substrings).
  #     Not a full LLM-output golden, but a static prompt-contract check.

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context>...</project-context>` (Option A locked shape — NO `name=` attr), it knows from psyche.md that it should merge the body into its in-memory project context and re-emit on the next context-save fire. SPT (Rust) routes the body to `projects/<name>/<self_id>.md`; the LLM is NOT taught any filesystem path."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The Psyche LLM does NOT attempt to derive or re-emit a project name token — `<project-context>` is no-name throughout (Option A locked). SPT (build_current_context_blocks per Plan 01) handles all project-name resolution."
      - "`plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` reference the same Option-A no-name `<project-context>` shape and use the phrasing 'emit this envelope; SPT routes it to disk' (NOT 'Psyche writes the file')."
      - "After Plan 03's encoding fix + this plan's psyche.md absorption rules + consolidated deploy, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
      - "psyche.md absorption rules state explicitly that they assume the Plan 03 encoding contract (body arrives at LLM-stdin in <encoded|decoded> state per Plan 03 SUMMARY's chosen Option) — so future editors can't accidentally re-encode."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New `<absorption>` section using Option A no-name shape and 'SPT routes to disk' phrasing."
        contains: "<project-context>"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (no-name shape; 'SPT routes' phrasing)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM emit instruction)"
        to: "src/owl/echo_commune.rs::route_two_slice"
        via: "no-name `<project-context>` envelope; Rust resolves destination project via Plan 01's resolver"
        pattern: "project-context"
      - from: "psyche.md absorption rules"
        to: "src/owl/poll.rs (Plan 03 encoding contract)"
        via: "body-state-at-LLM-stdin assumption (encoded or decoded per Plan 03 chosen Option)"
        pattern: "ENCODING CONTRACT"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms existing teaching (lines 244-354) covers both envelopes, but TWO gaps remain:

  1. **Absorption side gap (the primary fix):** Teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context>...</project-context>`, treat its body as project-context delta and merge it into your in-memory context." The architecture relies on round-trip-via-LLM — the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk via SPT routing. When inbound envelopes are silently ignored, content is lost.

  2. **Routing-clarity gap (codex HIGH):** Existing teaching is ambiguous about WHO writes the file. The corrected teaching is: "**emit this envelope; SPT routes it to disk**" — the LLM's job is EMIT, SPT's job is ROUTE. The LLM is not taught any filesystem path; route_two_slice (Plan 25 Plan 04) is the routing surface, not the LLM.

  Per LOCKED DECISIONS:
  - **Locked decision #1:** envelope shape is Option A (no-name `<project-context>`). NO `name=` attr anywhere. Producer/consumer envelope shape contract is UNCHANGED from pre-25.3. Minimal blast radius — no parser/writer changes.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (body-state-at-LLM-stdin). This plan's absorption rules reference that contract by name so future editors don't accidentally re-encode.
  - **Consolidated deploy:** This plan owns the integrated deploy of Plans 01+03+02 (single DEPLOY.ps1 invocation). Plan 04 has its own subsequent deploy.

  This plan cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync via DEPLOY; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` — strengthened absorption rules + Option A no-name shape + "SPT routes" phrasing + Plan 03 contract reference.
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - Binary rebuild + DEPLOY.ps1 -Bump patch (single consolidated deploy for Plans 01+03+02).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after the deploy.

  Purpose: Plan 01 made the haiku see the right project name. Plan 03 made the body uniformly decoded at LLM stdin. Plan 02 makes the haiku LLM actually emit `<project-context>` AND merge inbound. Without all three, the artifact never materializes.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO CURRENT_PROJECT_CONTEXT block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — STALE PHRASING; replace.
  - `<context_save>` section (line 325-355) reinforces emit-every-3-5-messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section teaching the LLM to detect and merge INBOUND `<project-context>` envelopes (no-name shape).
  - No rule that says "SPT routes the emit to disk; you do not name the project or know any path."
  - No reference to the Plan 03 encoding contract (body-state-at-LLM-stdin).

  From Plan 01 outcome (already landed by execution time):
  - `build_current_context_blocks` emits `CURRENT_PROJECT_CONTEXT (name=<X>):` header. The `(name=<X>)` is DEBUG/test-visible only — the LLM does NOT need to read it for routing purposes (SPT handles routing). The LLM MAY note it as context but MUST NOT emit it as a `name=` attr in `<project-context>` (Option A locked: no name attr).

  From Plan 03 outcome (already landed by execution time, read SUMMARY):
  - Body state at LLM stdin = <encoded|decoded> per chosen Option (Plan 03 Task 2). This plan's psyche.md absorption rules reference that state by name.

  From Plan 25 envelope code (UNCHANGED in this plan):
  - `parse_two_slice` reads literal `<live-context>` / `<project-context>` tags (no-name shape).
  - `route_two_slice` writes to `projects/<resolved_name>/<self_id>.md`, resolving `<resolved_name>` via Plan 01's helper at write time. The LLM never names the project.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 01 + Plan 03 SUMMARYs and extract handoff contracts</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` and `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract for the psyche.md edits:
      - From Plan 01: the header format that `build_current_context_blocks` now emits — exactly `CURRENT_PROJECT_CONTEXT (name=<X>):` per Plan 01 Task 2. psyche.md absorption rule will instruct the LLM: "the parenthetical `(name=<X>)` is purely informational; you MUST NOT echo it back as a `name=` attr."
      - From Plan 03: the body state at LLM stdin (encoded vs decoded). psyche.md will state this explicitly so future editors don't re-encode. The exact wording goes into a new "Encoding assumption" subsection of `<absorption>`.

      Record both findings inline in this plan's SUMMARY for traceability.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md; Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 01 header format extracted.
      - Plan 03 body-state-at-LLM-stdin extracted.
      - Both recorded in this plan's SUMMARY for downstream traceability.
    </done>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md (Option A locked; 'SPT routes' phrasing)</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. **Live-context absorption:** When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. **Project-context absorption:** When an inbound payload contains `<project-context>...</project-context>` (NO name attr — locked Option A shape), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>`.

      3. **Inbound merge is always active.** You do not need a CURRENT_LIVE_CONTEXT or CURRENT_PROJECT_CONTEXT prompt block to absorb inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. **SPT routes; you emit.** When you emit a `<project-context>...</project-context>` envelope, SPT (the Rust runtime) writes the body to `projects/<resolved_project>/<self_id>.md` and resolves `<resolved_project>` via Self's perch info.json `cwd` field (25.3-01 Defect B2 fix). You do NOT name the project; you do NOT know the filesystem path; the envelope shape is no-name throughout. If the prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header carries an `(name=<X>)` parenthetical, treat it as informational only — DO NOT echo it as a `name=` attr.

      5. **Encoding assumption (25.3-03 contract reference).** At the LLM-stdin boundary, the EVENT envelope body is in <STATE> form per Plan 03's chosen Option (read 25.3-03-SUMMARY.md Plan-04-handoff section; insert the chosen state literal here at execute time — typically "decoded" so `<live-context>` and `<project-context>` are LITERAL tags). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume.

      Update the existing `<output_envelope>` rule at psyche.md line 322 — remove the `<cwd_project>` phrasing entirely and replace with: "Receivers split the two-slice output by tag name (`<live-context>` and `<project-context>`); SPT resolves the destination project via Self's perch info.json (25.3-01 fix) — the LLM does NOT emit a project name."

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions; ensure emit rule is "emit `<project-context>` whenever a CURRENT_PROJECT_CONTEXT block (with or without name parenthetical) is present in the prompt".

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching + Option A shape clarification + encoding assumption.

      Keep total prompt growth under +70 lines (psyche.md is context-window-critical for every Psyche session).

      Step 4 — Static prompt-contract check (codex MEDIUM). Add a tiny test in `tests/native_owl.rs` (or `tests/psyche_md_contract.rs`):
      ```rust
      #[test]
      fn psyche_md_contains_25_3_absorption_section() {
          let content = include_str!("../psyche.md");
          assert!(content.contains("<absorption>"), "psyche.md must have <absorption> section");
          assert!(content.contains("Absorbing inbound two-slice envelopes"), "section title missing");
          assert!(content.contains("SPT routes"), "must teach 'SPT routes' phrasing (codex HIGH)");
          assert!(!content.contains("<project-context name="), "Option A locked: no name= attr");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("encoding contract"), "must reference Plan 03 contract");
      }
      ```
      This is a static prompt-contract test — not a full LLM-output golden, but it guards against accidental phrase drift in future edits.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'project-context' | Measure-Object).Count; (Select-String -Path psyche.md -Pattern 'live-context' | Measure-Object).Count; (Get-Content psyche.md | Measure-Object -Line).Lines; cargo test --release psyche_md_contains_25_3_absorption_section 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-5.
      - Option A no-name shape used throughout — NO `name=` attr anywhere.
      - "SPT routes; you emit" phrasing present (codex HIGH).
      - Encoding assumption references Plan 03 (codex HIGH dependency).
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - Static prompt-contract test passes.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching (Option A; 'SPT routes')</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches how to compose a commune/signoff body. Update:

      1. **Option A no-name shape (LOCKED):** producer emits `<project-context>...</project-context>` (NO `name=` attr) when the cwd is inside a tracked project. Remove any `name=` examples if present.

      2. **'SPT routes' phrasing (codex HIGH):** both files use the wording "emit this envelope; SPT routes it to disk." NOT "Psyche writes the file." NOT "the agent persists to the project file." The producer does not name the project; SPT resolves it via Self's perch info.json `cwd` field (25.3-01).

      3. **Routing destination naming:** both files reference the correct Phase 25 schema: `projects/<resolved_project>/<self_id>.md`. Old references to `projects/<self_id>.md` (flat layout — the user's mistaken expectation per the debug doc) must be corrected. Make explicit that `<resolved_project>` is resolved by SPT, not by the agent.

      4. **Cross-reference psyche.md absorption rules:** add a one-line pointer in each file: "Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules (25.3-02)."

      Do NOT introduce new commune subcommands, flags, or file-drop conventions. Strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically thinner; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1 (Task 4); no binary rebuild needed for skill changes.
    </action>
    <verify>
      <automated>Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'project-context' -SimpleMatch; Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'name=' -SimpleMatch | Select-Object -First 5</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<resolved_project>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - No-name shape used throughout (NO `name=` examples).
      - "SPT routes" phrasing present.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Consolidated deploy (Plans 01+03+02) + smoke trace</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe (Plans 01 + 03 changes already committed; rebuild picks up all three plans' edits).

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`. This is the CONSOLIDATED deploy for Plans 01+03+02.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire.
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (PowerShell):
        ```powershell
        Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200 -Wait
        ```
        (run_in_background: true since the agent perch is active.)
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes (literal `<` and `>`, NOT `&lt;`).

      Step 5 — Confirm artifact creation on disk (PowerShell, NO Bash):
      ```powershell
      Get-ChildItem "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify failure — the LLM may need additional prompt tightening, or Plan 04's wrapper-direct route may be required.

      Step 6 — Encoding-contract smoke (codex MEDIUM Plan 03 verification, run here since Plan 03 had no deploy):
      From the same tailed log, confirm the body content delivered to Psyche stdin shows uniform encoding state per Plan 03's chosen Option. NO mixed state.
    </action>
    <verify>
      <automated>Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped (consolidated deploy of Plans 01+03+02).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed; literal tags, no `&lt;`).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
      - Encoding-contract smoke confirms uniform state (NO mixed encoding).
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 03 + 02 together (consolidated):
      - Plan 01 (B2): corrected project-name resolver via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; consolidated deploy.

      Net outcome at this point: outbound `<project-context>` envelopes from the haiku materialize as `projects/<resolved_project>/<self_id>.md` on disk. Inbound envelope absorption is now taught but persistence still depends on the round-trip-via-LLM path (Plan 04 will optionally add wrapper-direct routing).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`).
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando using PowerShell here-string:
         ```powershell
         $body = @'
         <project-context>fresh content marker xyz</project-context>
         '@
         $env:OWL --% deliver todlando "$body"
         # (or use a temp file: $body | Set-Content -NoNewline tmp.txt; $env:OWL deliver todlando --file tmp.txt)
         ```
         Wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears.
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
      5. Read the log tail and confirm the haiku output contains literal `<project-context>` (not `&lt;project-context&gt;`) — Plan 03 verification.
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |
  | Skill files in marketplace clone | Deploy syncs `plugin/spt/skills/*` to the user's plugin cache via DEPLOY.ps1. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=...>` (HYPOTHETICAL — would only exist if Option B were chosen; Option A LOCKS this surface to non-existent) | accept | Option A locked: no-name shape throughout. Future re-introduction of name attr would require a new phase; the static prompt-contract test (`psyche_md_contains_25_3_absorption_section`) asserts no `<project-context name=` substring exists in psyche.md. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via the `(name=<X>)` parenthetical in prompt header | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14). The parenthetical is in the prompt input only; the LLM is taught not to echo it back. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-T2 | Tampering | LLM is tricked into emitting `<project-context name="../../../etc/passwd">` via prompt-injection | mitigate | Option A locked — the LLM is explicitly taught NOT to emit any `name=` attr. SPT-side: even if the LLM disobeyed, `parse_two_slice` ignores attributes (no-name shape parser); routing uses SPT-resolved project name (Plan 01 info.json `cwd` field), never user content. Path traversal blocked twice. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild. Cargo.lock not modified except by deterministic rebuild. DEPLOY.ps1 is in-repo signed script. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds (incorporates Plans 01+03+02 source changes into owl.exe).
  - DEPLOY.ps1 runs successfully; plugin version bumped (consolidated deploy).
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output with LITERAL inner tags (no `&lt;`).
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally.
  - Static prompt-contract test passes.
  - Encoding-contract smoke confirms uniform state at LLM-stdin (Plan 03 verification).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing.
  - commune.md + signoff/SKILL.md aligned with the same shape and phrasing.
  - Binary deployed; in-flight perches handed off.
  - `projects/<resolved_project>/<self_id>.md` artifact materializes on disk for todlando after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract test asserts no `<project-context name=` substring (Option A guard).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Plan 01 + Plan 03 handoff facts (header format + body-state-at-LLM-stdin) used in the psyche.md edits.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt (PowerShell `Get-Content -Tail`) showing both envelopes in haiku output with LITERAL inner tags.
  - Artifact verification: PowerShell `Get-ChildItem` of `projects/` directory contents + first 20 lines of `todlando.md`.
  - Operator-verify response.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test/check that verifies it.
  - Plan 04 handoff: "Inbound envelope absorption rules are in place; round-trip-via-LLM persistence is functional. Plan 04 EITHER adds wrapper-direct routing for resilience (Option 1) OR locks in the LLM round-trip with re-emit hardening (Option 2)."
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - src/owl/poll.rs
    - src/owl/echo_commune.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 2 (was wave 2 originally but
  # logically AFTER 02; cycle-2 puts it BEFORE 02 because 02's psyche.md absorption rules need
  # the body-state-at-LLM-stdin-boundary answer that 03 produces).
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 03 section):
  #   HIGH: "Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope
  #     string. Otherwise user body content can become structural-looking <EVENT> text in the wrong layer."
  #     → Task 1 trace + Task 3 fix: decode order is (1) parse EVENT framing first (attrs decoded as attrs,
  #     body extracted as raw substring), (2) decode body content only AFTER body extraction. Documented in
  #     the new contract block. Tests cover bodies containing literal `</EVENT>`, `<live-context>` nested,
  #     `<br>` literals.
  #   HIGH: "depends_on: [] is risky because this plan depends on the envelope shape chosen in Plan 02."
  #     → RESOLVED by locked decision #1 (Option A — no-name `<project-context>`). Plus reorder: 03 now
  #     precedes 02. `depends_on: [25.3-01]` (needs Plan 01's helper for resolver-aware encoding tests).
  #   MEDIUM: "'Decode at exactly ONE site' is too rigid. Attr decoding already exists, and multiple final
  #     consumers may need body decoding at their own boundary. The better invariant is 'one body decode per
  #     transport traversal.'" → Contract reframed in Task 3 step 3: "One body decode per transport
  #     traversal. Multiple final consumers MAY each apply their own decode at their own boundary, but a
  #     single body MUST NOT traverse two consecutive decode passes within one traversal."
  #   MEDIUM: "The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary
  #     test." → Task 3 Step 5 adds: split/reassembly tests where body content contains literal `</EVENT>`,
  #     `<br>`, and `&amp;` entities AT the chunk boundary.
  #   MEDIUM: "Several commands are Bash-style (tail, heredoc) despite the project's Windows-native
  #     constraint and PowerShell shell." → Task 4 uses PowerShell: `Get-Content -Tail`, PowerShell
  #     here-strings `@'...'@`, `$env:LOCALAPPDATA`. No Bash heredocs.
  #   LOW: "A large contract doc in poll.rs may age poorly if encoding behavior spans modules. A small
  #     encoding.rs module would be cleaner if churn is expected." → Task 3 Step 3 decision: if the contract
  #     doc exceeds ~80 lines OR introduces new helpers beyond `event_body_unescape`, EXTRACT to new
  #     `src/owl/encoding.rs` module. Otherwise inline in `poll.rs`. The decision threshold is documented
  #     so it's deterministic.

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract — attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most ONE body decode per transport traversal."
      - "When a commune EVENT envelope arrives at the Psyche LLM stdin, the body is either CONSISTENTLY encoded (outer EVENT framing escaped and inner `<live-context>` tags ALSO escaped throughout) or CONSISTENTLY decoded (outer framing already consumed by parser; inner `<live-context>` / `<project-context>` tags readable literal). NEVER the mixed state observed at 16:58:53 (outer raw + inner encoded)."
      - "If the producer side is over-encoding inner envelopes (double-encode), that bug is fixed at the source. If a wrapper relay path is adding a second encode, it is removed (relays are pure pass-throughs)."
      - "A new written-down decode contract lives in a single doc surface (`src/owl/poll.rs` top-of-module comment OR new `src/owl/encoding.rs` if the contract exceeds 80 lines / introduces new helpers)."
      - "EVENT-PART chunk-boundary tests cover bodies containing literal `</EVENT>`, `<br>`, and `&amp;` entities at split/reassembly boundaries — no chunk-boundary regression."
      - "User body content containing literal `</EVENT>` at any layer NEVER becomes structural (framing-injection blocked at producer encode; decode happens AFTER framing-parse so user `</EVENT>` cannot reframe upstream)."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites. NEW `event_body_unescape` helper (inverse of `event_body_escape`)."
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding state; EVENT-PART chunk-boundary tests."
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; one encode at producer, one decode AFTER body extraction at consumer"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract holds across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract.

  Per LOCKED DECISIONS:
  - **Plan reorder:** This plan now executes BEFORE Plan 02. Plan 02's psyche.md absorption rules need to know the body-state-at-LLM-stdin-boundary, which is THIS plan's output.
  - **Codex HIGH on decode boundary:** the contract is "attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most one body decode per transport traversal." NOT a global `String::replace` unescape across the entire envelope string.

  This plan:
  1. (Task 1) Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff). Identifies the asymmetric site.
  2. (Task 2) Records the encoding-contract direction decision (consumer sees encoded vs decoded) — checkpoint:decision.
  3. (Task 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the chosen consumer boundary; remove any double-encodes. Writes the contract doc. Adds tests including EVENT-PART chunk-boundary regressions.
  4. (Task 4) Build witness only — `cargo build --release` + `cargo test --release` full suite. NO deploy in this plan. Single integrated deploy lands in Plan 02 (after Plans 01+03 both finish).

  Note on Plan 04 dependency: Plan 04 (Defect A wrapper inbound routing) DEPENDS on this plan's contract — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Task 1's chain trace + Task 2's chosen option supplies Plan 04's input.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites:
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs.

  Decode sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also from-attr decoder).
  - (?) body decode — NO body decode site currently exists. THIS IS THE CONTRACT GAP.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no edits)</files>
    <action>
      Run PowerShell-friendly greps:
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern '&amp;|&lt;|&gt;|&quot;|<br>' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'deliver_body|deliver_body_anonymous|deliver_body_deferred' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch
      ```

      For each emit site (every `format!`/`write!` that constructs an EVENT envelope):
      - Note file:line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site:
      - Note file:line.
      - Note what is decoded (attrs / body / both / neither).
      - Note destination (LLM stdin / Self prompt inject / written to file).

      Trace ONE inbound commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop).
      2. Self listener (poll.rs) detects file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151).
      3. Self listener delivers via owl REST to Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives file_drop EVENT, parses kind+path, reads file contents (raw filesystem read — no encoding), composes `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER.
      5. Wrapper sends composed envelope to Psyche LLM stdin via `resume_session_with_exit`.

      Trace the OUTBOUND echo_commune path:
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH step record: encoded? decoded? pass-through? Identify the asymmetric site.

      Produce a CHAIN TABLE in the SUMMARY:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (find via the grep above — likely in `src/live/wrapper/mod.rs` or sibling). Document its body-encoding behavior exactly.

      State the root-cause hypothesis explicitly and testably in SUMMARY.
    </action>
    <verify>
      <automated>(Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line with specific reason.
      - Root cause hypothesis stated, testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Body-decode boundary — where does the body get decoded to literal form?</decision>
    <context>
      Per locked-decision context (Plan 02 absorption rules need this answer): the Psyche LLM must SEE literal `<live-context>` and `<project-context>` tags (not `&lt;` / `&gt;`). So at SOME consumer-side boundary, the body must be decoded.

      The producer-side encode is necessary because outer `<EVENT>` framing must be unambiguous in spool storage / TCP transport (literal `<EVENT>` inside the body would corrupt framing).

      Per codex HIGH: decode happens AFTER EVENT framing is parsed (NOT a global string unescape). The question is WHERE the body-decode step lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single canonical decode site. Spool/TCP/wrapper-inner-poll all carry encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool/TCP carry encoded; everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay (if relay re-encodes for retransmit, double-encode regression risk).

      Option C — No decode anywhere; teach LLM to handle `&lt;live-context&gt;` literally. Rejected (breaks Plan 02 absorption rules).

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` on inbound envelope, body MUST be decoded first so `parse_two_slice` can find literal `<live-context>`. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether body is already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary (after body extraction)</name>
        <pros>Single canonical decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-encode-once symmetry; Plan 04 can decode in process_file_drop.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication (one extra call to `event_body_unescape`).</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded; harder to audit.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site, write contract doc, add tests including chunk-boundary</name>
    <files>src/owl/poll.rs, src/owl/echo_commune.rs, src/live/wrapper/mod.rs, src/live/commune.rs, tests/native_owl.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - New helper `event_body_unescape` (inverse of `event_body_escape`) added to `src/owl/poll.rs` if it does not already exist. Decode order: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (amp-last invariant — Pitfall 3 inverse, mirrors `parse_event_from_attr` precedent at poll.rs:720).
      - Body decode invoked at the chosen consumer boundary, AFTER EVENT framing has been parsed (NOT a global string-unescape of the entire envelope string).
      - Round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>`.
      - EVENT-PART chunk-boundary regression: a body containing literal `</EVENT>`, `<br>`, and `&amp;` SPLIT across EVENT-PART chunks (Phase 27 chunker) reassembles correctly and decodes cleanly with no mid-chunk corruption.
      - Producer-side double-encodes (if any) removed at the source — NOT compensated downstream.
      - Wrapper relays are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Codex MEDIUM reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site"). Multiple final consumers may each decode at their own boundary.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691):

      ```rust
      /// Inverse of `event_body_escape`. Decode order MUST be `<br>` first, then
      /// tag-shaped entities, with `&amp;`→`&` LAST (Pitfall 3 inverse — same
      /// invariant as `parse_event_from_attr` at poll.rs:720).
      ///
      /// 25.3-03 Defect C: this is the body-side counterpart to the existing
      /// attr-side inverse. Call site invariant: invoke ONLY after EVENT framing
      /// has been parsed (i.e. body has been extracted as a substring); NEVER
      /// apply to the full envelope string globally — that would let user body
      /// content containing literal `</EVENT>` reframe upstream layers.
      ///
      /// Invariant: at most ONE body decode per transport traversal.
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of (choose per Task 1 findings):
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` is encoding a body that was already encoded upstream).
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B) — AFTER body extraction.
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      Document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract. Default location: top-of-module comment in `src/owl/poll.rs`. EXTRACTION TRIGGER: if the contract block exceeds 80 lines OR if Step 2's fix introduces new helpers beyond `event_body_unescape`, EXTRACT to new `src/owl/encoding.rs` module and re-export the existing escape helpers from there (preserve all call sites). Otherwise inline in `poll.rs`.

      Block must cover:
      - Where encoding happens (every producer emit site, file:line).
      - Where decoding happens (the chosen consumer boundary; amp-last invariant; AFTER body extraction).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see (encoded throughout OR encoded up to poll only, per chosen option).
      - Reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site").
      - Forbidden combinations: no double-encode; no global string unescape of the full envelope; no decode BEFORE body extraction.

      Step 4 — Unit tests in `tests/native_owl.rs` (use ONE `cargo test` filter per invocation per cross-plan recommendation):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` (body contains `</EVENT>` — must NOT reframe)
      - `event_body_unescape_inverts_event_body_escape_for_literal_br_in_body` (body contains literal `<br>` text)
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly).

      Step 5 — EVENT-PART chunk-boundary tests (NEW per codex MEDIUM):
      - `event_part_chunk_boundary_with_literal_end_event_in_body` — split a body containing `</EVENT>` mid-chunk; assert reassembly is byte-identical; decode is clean.
      - `event_part_chunk_boundary_with_br_token_at_split` — `<br>` straddles the chunk boundary; assert reassembly preserves the literal `<br>` token.
      - `event_part_chunk_boundary_with_amp_entity_at_split` — `&amp;` straddles the chunk boundary; assert reassembly + amp-last decode produces a single `&`.

      Locate the EVENT-PART chunker (Phase 27) via grep `EVENT-PART|EventPart|split_into_chunks` and use the existing test scaffolds.

      Step 6 — End-to-end test (new file `tests/encoding_chain_end_to_end.rs` OR extend `tests/native_owl.rs` — pick by existing test-file conventions):
      - Fabricate a commune body containing literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>`.
      - Run through full pipeline using test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that hits Psyche stdin.
      - Assert: final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND inner envelope tags are literal `<` / `>`. NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with amp-last invariant.
      - Asymmetric site fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists (in `poll.rs` or extracted to `encoding.rs` per the 80-line threshold).
      - Reframed invariant present: "one body decode per transport traversal."
      - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Full test suite + cargo build witness (NO deploy in this plan)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests). Use ONE filter at a time if scoping; for the full suite no filter.

      Step 2 — `cargo build --release`.

      Step 3 — NO `DEPLOY.ps1` in this plan. Per cross-plan recommendation, deploy is consolidated:
      - Plan 02 will perform the single integrated deploy AFTER Plans 01+03 land (Plan 02's Task 4).
      - Plan 04 will perform the final deploy after the wrapper routing fix lands.

      Step 4 — Smoke-trace placeholder (deferred): record in SUMMARY the EXACT PowerShell command Plan 02 should run to verify the encoding fix post-deploy:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200
      # Look for the next [PSYCHE] resume entry; inspect body for:
      #  - Outer EVENT tag readable (<EVENT type="commune" ...>)
      #  - Inner <live-context>marker-live-xyz</live-context> readable (literal < and >, NOT &lt; / &gt;)
      #  - Inner <project-context>marker-proj-abc</project-context> readable
      #  - No mixed encoding state
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY (codex strength — preserve): "When `process_file_drop` receives a file_drop EVENT, the body is in state X" where X = encoded/decoded per the chosen Option from Task 2. Plan 04 Task 1 reads this directly.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Full test suite passes.
      - `cargo build --release` succeeds.
      - NO deploy in this plan; Plan 02 owns the consolidated deploy.
      - SUMMARY contains the post-deploy smoke command (PowerShell) and Plan 04 handoff note.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. New `event_body_unescape` inverse. Single encode at producer; one body decode per transport traversal at the chosen consumer boundary (Option A or B). EVENT-PART chunk-boundary tests added. Single source-of-truth contract doc block in `src/owl/poll.rs` (or `encoding.rs`). NO deploy yet (Plan 02 deploys after both 01+03).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `src/owl/encoding.rs`). Confirm it describes encode/decode points, the amp-last invariant, AND the reframed "one body decode per transport traversal" invariant — unambiguously.
      2. Inspect the SUMMARY's chain trace table — confirm only ONE encode and AT MOST ONE decode per traversal in the full chain.
      3. Run `cargo test --release` locally (or trust the Task 4 output). Confirm encoding-chain + chunk-boundary tests pass.
      4. Optional: read the asymmetric-site fix diff — confirm the fix is at the SOURCE (not compensated downstream).
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean, the asymmetric site is fixed at the source, and the chunk-boundary tests pass. Or describe remaining asymmetry / failing tests.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`, literal `</EVENT>`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects per chosen Option. |
  | EVENT-PART chunk reassembly | Phase 27 chunker reassembles split bodies; boundary correctness is critical. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. The decode happens AFTER framing has been parsed (codex HIGH) — the body extracted from a parsed envelope cannot reframe upstream layers because they have already consumed the structural tokens. Tested via `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body`. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode of `&amp;lt;` → `<` instead of `&lt;`) | mitigate | Amp-last invariant documented in `event_body_unescape` doc + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-T3 | Tampering | Body content reframes upstream via global string unescape (codex HIGH) | mitigate | The contract explicitly forbids decoding the full envelope string globally. Decode invoked ONLY on the body-extracted substring (post-framing-parse). Contract doc states this; tests enforce. |
  | T-25.3.03-T4 | Tampering | Chunk-boundary corruption at EVENT-PART reassembly | mitigate | New chunk-boundary tests cover `</EVENT>`, `<br>`, `&amp;` straddling splits. Reassembly is byte-identical; decode is post-reassembly. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly. EVENT-PART chunker (Phase 27) caps individual chunk sizes; full-body cap if any exists is documented in `<encoded_body_cap>` (flag for follow-up if no cap exists). |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - NO deploy from this plan.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  - Plan 02 has an unambiguous answer to "what state does the body arrive in at LLM-stdin?" — same source.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root-cause statement.
  - Chosen option (A or B) + rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim — same string that lives in the codebase).
  - Test names + pass evidence (including 3 chunk-boundary tests).
  - Plan 02 handoff note: "Body state at LLM-stdin = <encoded|decoded>" per chosen Option.
  - Plan 04 handoff note: "Body state at process_file_drop = <encoded|decoded>" per chosen Option.
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 4
  depends_on:
    - 25.3-01
    - 25.3-03
    - 25.3-02
  files_modified:
    - src/live/wrapper/mod.rs
    - src/common/tracked.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 4 (was wave 3); 02 precedes 04.
  # Option 1 is LOCKED with stale-overwrite mitigation per codex HIGH. Option 2 (round-trip-via-LLM)
  # is explicitly DISFAVORED.
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 04 section):
  #   HIGH: "Option 1 ignores Plan 02 Option B's possible `name=` attr. If named project-context envelopes
  #     are adopted, routing by receiver last_project_name may write cross-project context to the wrong
  #     file." → RESOLVED by Plan 02 locked Option A (no-name shape). Routing always uses receiver-side
  #     resolver (Self's perch info.json `cwd` via Plan 01's helper). No name attr to consider.
  #   HIGH (locked decision #2): "Routing must use receiver-side resolver (info.json cwd per locked
  #     decision #2), NOT name= attr." → Task 2 routes via Plan 01's `resolve_self_project_name_via_info_cwd`
  #     helper. Same resolver as build_current_context_blocks — no third-site resolver.
  #   HIGH: "Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body.
  #     The plan acknowledges this but does not mitigate it." → Task 2 Step 2 adds STALE-OVERWRITE
  #     MITIGATION via monotonic content-merge: route_two_slice gets a new `inbound_baseline: Option<&str>`
  #     parameter (or sibling helper) that compares the new body against the existing on-disk content;
  #     if new body is a STRICT SUBSET (LLM emitted stale content that drops markers introduced by direct
  #     route), the write is REJECTED and a warning is logged. See task action for full design.
  #   HIGH: "Option 2 (round-trip-via-LLM) was the failure mode — DISFAVOR unless paired with enforcement.
  #     Plan should LOCK Option 1." → Option 1 is LOCKED in this plan. Task 1 checkpoint:decision retained
  #     but with a default + strong recommendation toward Option 1. Option 2 explicitly disfavored.
  #   MEDIUM: "Route failures must surface clear warning/status (not silently nonfatal)." → Task 2 Step 3:
  #     route failures emit `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)`
  #     as a structured WARN-level log; also bump a counter in info.json `route_failure_count` (optional
  #     telemetry — defer if scope too tight). Non-fatal but VISIBLE.
  #   MEDIUM: "Extract small testable helper (route_inbound_commune_body) instead of testing through full
  #     wrapper process flow. Call out dependency injection." → Task 2 Step 1 extracts
  #     `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-
  #     testable seam. process_file_drop invokes the seam; tests target the seam directly without mocking
  #     resume_session_with_exit.
  #   LOW: "$OWL deliver heredoc not PowerShell-compatible." → Task 4 uses PowerShell here-string `@'...'@`
  #     piped into a temp file or PowerShell `--%` literal-args mode. Bash heredocs eliminated throughout.

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<resolved_project>/<self_id>.md` on disk via wrapper-side `route_two_slice` call BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver (Self's perch info.json `cwd` → `project_name_from_cwd_path`). NOT a `name=` attr (Option A locked, no such attr exists)."
      - "STALE-OVERWRITE MITIGATION: the next echo_commune fire's LLM output CANNOT erase the direct-routed content. Monotonic merge guard: if the LLM's emitted `<project-context>` body is a STRICT SUBSET of the on-disk content (key markers missing), the merge writer warns and rejects the write OR keeps the larger version per defined rule (see Task 2)."
      - "The wrapper's `process_file_drop` is the gatekeeper for inbound routing; no other inbound envelope route exists."
      - "Route failures emit a STRUCTURED WARN log (`[FILE-DROP] route_two_slice failed for {self_id}: ...`); non-fatal but VISIBLE (codex MEDIUM)."
      - "End-to-end test demonstrates: inbound payload with marker text → marker appears in `projects/<name>/<self_id>.md` within SECONDS (Option 1 timing — synchronous, before LLM handoff)."
      - "Implementation is unit-testable via the extracted `route_inbound_commune_body(self_id, body, source)` helper (codex MEDIUM); tests do NOT need to mock `resume_session_with_exit`."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop calls new `route_inbound_commune_body` helper before LLM handoff (Option 1 LOCKED). Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/common/tracked.rs"
        provides: "New `route_two_slice_with_baseline` variant (or new `route_two_slice_monotonic` sibling) that implements stale-overwrite mitigation. Or a small wrapper `merge_project_body_monotonic`."
        contains: "monotonic"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + stale-overwrite mitigation tests."
        contains: "route_inbound_commune_body"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/live/wrapper/mod.rs::route_inbound_commune_body (NEW seam)"
        via: "synchronous direct-route call BEFORE LLM handoff"
        pattern: "route_inbound_commune_body"
      - from: "src/live/wrapper/mod.rs::route_inbound_commune_body"
        to: "src/owl/echo_commune.rs::resolve_self_project_name_via_info_cwd (Plan 01)"
        via: "receiver-side project resolver — same source as build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd|project_name_from_cwd_path"
      - from: "src/common/tracked.rs::route_two_slice"
        to: "stale-overwrite mitigation (monotonic merge guard)"
        via: "comparison of LLM-emit body against current on-disk content; subset → reject + warn"
        pattern: "monotonic|merge_project_body"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with codex HIGH mitigations locked in. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — never calls `route_two_slice` on the inbound envelope. Inbound `<project-context>` envelopes are silently lost when the LLM round-trip drops them (the failure mode that triggered Phase 25.3).

  Per LOCKED DECISIONS + codex HIGH:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies via `route_two_slice` BEFORE LLM handoff. Option 2 (round-trip-via-LLM) was the original failure mode and is explicitly DISFAVORED — codex HIGH. The checkpoint:decision in Task 1 records the choice but defaults to Option 1.

  - **Receiver-side resolver (locked decision #2).** Routing uses Plan 01's `resolve_self_project_name_via_info_cwd` (info.json `cwd` field) for project resolution — same source as `build_current_context_blocks`. NO `name=` attr (Option A locked in Plan 02).

  - **Stale-overwrite mitigation (codex HIGH).** After direct-route persists the inbound body, the next echo_commune fire's LLM output MUST NOT erase the routed content. Solution: monotonic merge guard — `route_two_slice` (or a sibling) compares new content against existing on-disk content; if the new body is a STRICT SUBSET (drops markers), the write is rejected and a warning is logged. The exact merge policy is defined in Task 2.

  - **Testable seam (codex MEDIUM).** Extract `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-testable helper. Tests target the seam directly — do NOT mock `resume_session_with_exit` or run a full wrapper process flow.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in <STATE> form per Plan 03 SUMMARY. Task 2 reads that handoff note.

  Output:
  - User decision recorded at Task 1 checkpoint (default Option 1 with strong recommendation).
  - `route_inbound_commune_body` helper extracted as testable seam.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - Stale-overwrite mitigation: monotonic merge guard in `route_two_slice` (or sibling).
  - End-to-end test demonstrating synchronous inbound persistence.
  - Binary rebuild + DEPLOY (final patch bump for Phase 25.3).
  - Operator-verify checkpoint.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for Option 1 implementation. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318).
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315).
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).

  From src/owl/echo_commune.rs (Plan 01 outcome):
  - `fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01. Reads Self's perch info.json `cwd` field and resolves via `project_name_from_cwd_path`.
  - This plan REUSES that helper at the wrapper site (the third resolver consumer — codex MEDIUM noted duplication concern is now a single shared helper).

  From Plan 03 outcome (read 25.3-03-SUMMARY.md at execute time):
  - Body state at wrapper file_drop boundary = <encoded|decoded>. If Plan 03 chose Option A (decode at LLM-stdin), the wrapper file_drop site sees DECODED body (because filesystem read yields raw user prose with literal tags). If Plan 03 chose Option B (decode at poll), same outcome: decoded body. Either way: at process_file_drop, body has LITERAL `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - The LLM is taught "SPT routes" — meaning this plan IS that SPT routing.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 03 SUMMARY to confirm body-state at process_file_drop boundary</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract the Plan-04-handoff line: "Body state at process_file_drop = <encoded|decoded>."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      If the body is ENCODED at process_file_drop, Task 2 inserts a `crate::owl::poll::event_body_unescape` call before invoking `route_inbound_commune_body`.

      If the body is DECODED (expected case — filesystem read of user-written commune file), no decode pass needed.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read.
      - Body state at process_file_drop documented in this plan's SUMMARY.
      - Task 2 has the decode-or-not answer.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper inbound routing strategy — Option 1 (LOCKED default) vs Option 2 (DISFAVORED)</decision>
    <context>
      Per locked decisions + codex HIGH: Option 1 (direct wrapper route before LLM handoff) is LOCKED as the default and strongly recommended. Option 2 (round-trip-via-LLM with re-emit hardening) is DISFAVORED — it relies on prompt compliance, which is the failure mode that triggered this phase. This checkpoint exists for record-keeping; the strong recommendation is Option 1.

      Option 1 (LOCKED default) — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call the new `route_inbound_commune_body(self_id, body, "commune-inbound")` seam.
      - Synchronous persistence; inbound envelope hits disk immediately.
      - Survives a Psyche LLM that drops the envelope.
      - STALE-OVERWRITE MITIGATION (codex HIGH): the next echo_commune fire's LLM output cannot erase the routed content (monotonic merge guard).
      - Failure mode of original Defect A is eliminated.

      Option 2 (DISFAVORED) — Do not modify process_file_drop; rely on Plan 02's absorption + re-emit teaching only.
      - Smaller code surface but no defense against LLM dropping the envelope.
      - Only acceptable if the user actively chooses this path over codex's HIGH recommendation.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_inbound_commune_body before LLM handoff (LOCKED DEFAULT)</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune. Receiver-side resolver via Plan 01 (no third resolver site — same shared helper). Stale-overwrite mitigation via monotonic merge guard.</pros>
        <cons>Marginally more code than Option 2; requires the monotonic merge guard.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM only (DISFAVORED per codex HIGH)</name>
        <pros>Smaller code surface.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss. Codex HIGH explicitly disfavors. Do not select unless user has a specific reason.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 (default; press enter to accept) or option-2 (must explicitly type with rationale).</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Extract route_inbound_commune_body seam + wire process_file_drop + add stale-overwrite mitigation</name>
    <files>src/live/wrapper/mod.rs, src/common/tracked.rs</files>
    <behavior>
      - SKIP this task if Option 2 chosen at Task 1; proceed to Task 3.
      - `route_inbound_commune_body(self_id, body, source)` (NEW helper in `src/live/wrapper/mod.rs` or a sibling module) is unit-testable WITHOUT mocking `resume_session_with_exit` or running a full wrapper process.
      - When invoked with a body containing `<project-context>marker-abc</project-context>`, `projects/<resolved_project>/<self_id>.md` contains `marker-abc` on disk after return (assuming Plan 01's resolver returns a valid project for self_id).
      - When invoked with a body containing NO envelope tags, parse_two_slice puts the entire body in the live slot (existing fallback) → `live_context.md` is updated; no project write.
      - When invoked and resolver returns None (no project resolved for self_id), the project slice is dropped silently per existing route_two_slice D-09; live slice still routes.
      - **Stale-overwrite mitigation:** when a SUBSEQUENT echo_commune fire's LLM output emits a `<project-context>` body that is a STRICT SUBSET of the current on-disk content (a defined "subset" rule — see action), the merge writer REJECTS the write and emits a structured WARN log. This protects the direct-routed inbound marker from being erased by a stale LLM emit.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Route failures are structured WARN logs and non-fatal — LLM handoff continues regardless.
      - "signoff" arm is unchanged.
    </behavior>
    <action>
      Step 1 — Extract the testable seam in `src/live/wrapper/mod.rs`. Add a new function near `process_file_drop`:

      ```rust
      /// 25.3-04 Defect A — extracted testable seam for inbound commune routing.
      ///
      /// Parses `body` via `parse_two_slice`, resolves Self's project via the
      /// receiver-side resolver from Plan 01 (info.json `cwd` field), and routes
      /// each slice to disk. NO LLM handoff in this function — that is the caller's
      /// responsibility.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM):
      /// callers pass `self_id` + raw body; the function consumes Plan 01's resolver
      /// and Plan 25's route_two_slice (with monotonic merge guard for project slice).
      ///
      /// Returns RouteOutcome on success (records which slices were routed);
      /// Err(String) on hard failures (resolver unreachable, IO error).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,
      ) -> Result<RouteOutcome, String> {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());

          // Plan 03 contract reference: body is in DECODED form at this site
          // (filesystem read of user-written commune file).
          // If Plan 03 SUMMARY's handoff note says ENCODED, decode here:
          // let body = crate::owl::poll::event_body_unescape(body);
          // (Task 0 already confirmed the correct branch.)

          // route_two_slice_monotonic is the new sibling that applies the
          // stale-overwrite guard for the project slice. For inbound source =
          // "commune-inbound", we want UNCONDITIONAL write of the inbound delta
          // (this IS the source of truth at this moment), so we use the standard
          // route_two_slice. The monotonic guard fires on the SUBSEQUENT
          // echo_commune emit only — see Step 2.
          crate::common::tracked::route_two_slice(self_id, body, source, &short)
              .map(|_| RouteOutcome { live_routed: true, project_routed: true })
              .map_err(|e| e.to_string())
      }

      pub(crate) struct RouteOutcome {
          pub live_routed: bool,
          pub project_routed: bool,
      }
      ```

      Step 2 — Add the STALE-OVERWRITE MITIGATION in `src/common/tracked.rs`. The guard fires on the OUTBOUND echo_commune emit path (NOT on inbound — inbound is the source of truth). Implementation: add a new function `route_two_slice_monotonic` OR extend `route_two_slice` with an internal subset-check for the project slice.

      Recommended approach: add `fn route_two_slice_monotonic(self_id, body, source, short)` as a SIBLING that wraps `route_two_slice` with this additional guard ONLY for the project slice:

      ```rust
      /// 25.3-04 stale-overwrite mitigation. Identical to `route_two_slice` except:
      /// before writing the project slice, READ the current on-disk project file.
      /// If the new body is a STRICT SUBSET of the existing content per the
      /// `is_strict_subset` predicate (see below), REJECT the write and emit a
      /// structured WARN log: `[ROUTE-GUARD] rejected stale project body for {self_id}
      /// (new body is strict subset of on-disk; preserving existing)`.
      ///
      /// Subset rule (defined per 25.3-04 Task 2 codex HIGH mitigation):
      ///   `is_strict_subset(new, existing)` = true iff EVERY non-blank line of
      ///   `existing` is present in `new` is FALSE for at least one line AND every
      ///   non-blank line of `new` appears in `existing`. Translation: `new` is
      ///   missing markers that `existing` has, and `new` does not introduce any
      ///   new content. This is the "LLM dropped content" signature.
      ///
      /// Live slice is ALWAYS unconditionally written (no monotonic guard on
      /// live_context.md — it is intentionally ephemeral).
      pub fn route_two_slice_monotonic(
          self_id: &str,
          body: &str,
          source: &str,
          short: &str,
      ) -> Result<(), String> { /* impl */ }
      ```

      Wire the OUTBOUND echo_commune fire (`src/owl/echo_commune.rs::run_echo_commune` at line 854) to use `route_two_slice_monotonic` instead of `route_two_slice`. INBOUND wrapper-direct route (Task 2 Step 1) keeps the unconditional `route_two_slice`.

      Step 3 — Update `process_file_drop` in `src/live/wrapper/mod.rs`. Inside the "commune" arm, BEFORE composing the EVENT envelope (currently at line 1278):

      ```rust
      // 25.3-04 Option 1 (LOCKED): wrapper-direct route inbound commune body
      // BEFORE LLM handoff. The LLM still receives the envelope below; the route
      // is a synchronous SIDE EFFECT that hardens against LLM drops. Stale-
      // overwrite is mitigated by the monotonic guard on the OUTBOUND
      // echo_commune emit (route_two_slice_monotonic).
      if kind == "commune" {
          match route_inbound_commune_body(&self.self_id, &body, "commune-inbound") {
              Ok(outcome) => self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound; live={}, project={})",
                  self.self_id, outcome.live_routed, outcome.project_routed
              )),
              Err(e) => self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              )),
          }
      }
      ```

      Codex MEDIUM (visible failures): the WARN log is structured with a stable prefix `[FILE-DROP]` so operators can `Select-String -Pattern '\[FILE-DROP\] route_two_slice failed'` to find failures.

      Step 4 — Unit tests targeting the seam (NO wrapper mocking — codex MEDIUM):
      - `route_inbound_commune_body_writes_project_slice_with_marker` — set up fake tracked tree + info.json with cwd → temp .git repo; pass body with `<project-context>marker-abc</project-context>`; assert `projects/<name>/<self_id>.md` contains "marker-abc". `#[serial_test::serial]` (env-mutating).
      - `route_inbound_commune_body_writes_live_slice_only_when_no_project_envelope` — body with only `<live-context>...</live-context>`; assert live_context.md updated; no project file. `#[serial]`.
      - `route_inbound_commune_body_no_envelope_tags_fallback` — body with no tags; parse_two_slice fallback routes to live slot. `#[serial]`.
      - `route_inbound_commune_body_returns_err_when_resolver_fails` — info.json missing AND process cwd resolves to psyche_dir; assert resolver fails gracefully (the existing route_two_slice D-09 silent-drop applies). `#[serial]`.

      Stale-overwrite mitigation tests (target `route_two_slice_monotonic` directly):
      - `route_two_slice_monotonic_rejects_strict_subset_project_body` — write `marker-a\nmarker-b\nmarker-c` to project file; then call with body `<project-context>marker-a\nmarker-b</project-context>`; assert file content UNCHANGED + WARN log emitted. `#[serial]`.
      - `route_two_slice_monotonic_accepts_strict_superset` — same setup; call with body adding `marker-d`; assert file content updated to include marker-d. `#[serial]`.
      - `route_two_slice_monotonic_accepts_overlapping_with_new_content` — new body shares some markers but adds a new one; assert write goes through. `#[serial]`.
      - `route_two_slice_monotonic_always_writes_live_slice` — verify the monotonic guard applies ONLY to project slice; live_context.md is unconditionally updated. `#[serial]`.

      End-to-end ordering test:
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` — verify route_inbound_commune_body is called BEFORE resume_session_with_exit in the "commune" arm. This can be a structural test (read process_file_drop source order) OR a small mock-injection test if existing test scaffolds permit. If structural, document as such.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_monotonic 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, unit-testable without LLM mocking.
      - `route_two_slice_monotonic` exists in `src/common/tracked.rs` with `is_strict_subset` predicate.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
      - Outbound echo_commune fire uses `route_two_slice_monotonic` (stale-overwrite mitigation).
      - Inbound wrapper-direct uses standard `route_two_slice` (unconditional, source of truth at that moment).
      - Route failures are structured WARN logs (operator-visible).
      - 4 seam tests + 4 monotonic-guard tests + 1 ordering test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path — DISFAVORED): Harden psyche.md re-emit guarantee only</name>
    <files>psyche.md</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1 (the LOCKED default). Proceed to Task 4.

      This task only executes if the user explicitly overrode the codex HIGH recommendation and selected Option 2. In that case:

      Add to psyche.md `<absorption>` section (built in Plan 02), a new rule:

      "**Re-emit guarantee (Option 2 — 25.3-04):** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Add a Rust-side regression detector in `src/owl/echo_commune.rs::run_echo_commune` (after haiku stdout capture, before route_two_slice at line 854):
      - If `prompt.contains("CURRENT_PROJECT_CONTEXT")` AND NOT `output.contains("<project-context>")`, log:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (25.3-04 Option 2 re-emit guarantee violated)", self_id);
        ```

      This is observability only — Option 2 explicitly does NOT enforce.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'Re-emit guarantee' -SimpleMatch | Measure-Object).Count; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - (Option 2 only) psyche.md has "Re-emit guarantee" rule.
      - (Option 2 only) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (Option 1 path)</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — Final deploy for Phase 25.3:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      STOP before `/reload-plugins` (per CLAUDE.md). User runs it.

      Step 4 — After user reload, send a deliberate inbound commune to todlando using PowerShell (NOT Bash heredoc — codex LOW):

      Approach A — temp file:
      ```powershell
      $body = @'
      <project-context>marker-25.3-04-Z timestamp-test</project-context>
      '@
      $tmp = New-TemporaryFile
      Set-Content -NoNewline -Path $tmp -Value $body
      & $env:OWL deliver todlando --file $tmp
      Remove-Item $tmp
      ```

      Approach B — PowerShell literal-args mode `--%`:
      ```powershell
      $env:OWL --% deliver todlando "<project-context>marker-25.3-04-Z timestamp-test</project-context>"
      ```

      Use whichever approach the `$OWL deliver` CLI supports — inspect `--help` first.

      Step 5 — Verification (Option 1 timing):
      Within SECONDS (before the next echo_commune fire — Option 1 is synchronous), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok for todlando \(commune-inbound'
      ```

      Step 6 — Stale-overwrite mitigation smoke: wait for the next echo_commune fire and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that is a STRICT SUBSET (missing the marker), confirm the `[ROUTE-GUARD] rejected stale project body` log line appears in todlando.log.
      - If the haiku emitted a SUPERSET (preserving the marker + adding more), confirm the file content is the new superset.

      Step 7 — Confirm the on-disk file content's marker text round-tripped intact (no encoding artifacts — Plan 03's chain verification).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for Phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` within SECONDS of inbound commune.
      - `[FILE-DROP] route_two_slice ok` log line confirmed for the inbound.
      - Stale-overwrite mitigation verified: either marker preserved across next echo_commune fire OR `[ROUTE-GUARD] rejected stale project body` log line confirmed.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 5: Phase-level invariant test (cross-plan; codex cross-plan recommendation #6)</name>
    <files>tests/native_owl.rs</files>
    <action>
      Add one phase-level invariant test per codex cross-plan recommendation #6:

      `phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`

      Scenario:
      - Set up a temp SPT_HOME with a fake perch info.json for `test-agent` carrying `cwd = <path to a temp .git repo named "test_project">`.
      - Invoke `route_inbound_commune_body("test-agent", body, "commune-inbound")` with body:
        ```
        <live-context>live-marker-aaa</live-context>
        <project-context>project-marker-bbb</project-context>
        ```
      - Assert ALL THREE invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO HTML-encoded tags in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;`, no `&amp;`). (Defect C invariant.)
        (c) Marker text in expected file — `projects/test_project/test-agent.md` contains `project-marker-bbb`; `agents/test-agent/live_context.md` contains `live-marker-aaa`. (Defects D + A invariant.)
      - `#[serial_test::serial]` (env-mutating).

      This is the single integration test that validates the entire phase 25.3 fix as a unit. If this test passes, the four defects are jointly resolved.
    </action>
    <verify>
      <automated>cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly 2>&1 | Select-Object -Last 15</automated>
    </verify>
    <done>
      - Phase-level invariant test exists and passes.
      - Test asserts no `"tracked"` artifact, no HTML-encoded tags, correct marker placement.
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; stale-overwrite mitigation via `route_two_slice_monotonic`; structured failure logs.
      - Cross-plan: phase-level invariant test (Task 5) jointly validates all four defects.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk, with stale-overwrite protection against subsequent LLM drops.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to todlando via PowerShell:
         ```powershell
         $body = @'
         <project-context>final-verify-marker-25.3</project-context>
         '@
         # via temp file or $env:OWL --% deliver — see Task 4 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok'
         ```
      4. Send a SECOND `<project-context>` with a different marker and confirm both markers coexist OR the second's content correctly supersedes (depending on whether the second is a superset or causes the monotonic guard to fire).
      5. Confirm `live_context.md` continues to update normally (no regression on live slice).
      6. Confirm no `projects/tracked/` directory exists (Defect B2 invariant).
      7. Run phase-level invariant test: `cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`.
    </how-to-verify>
    <resume-signal>Type "approved" if all four defects are jointly resolved per the verification steps, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md`. |
  | Monotonic merge guard | Reads existing on-disk content; compares against new body; rejects on strict-subset. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | Option A no-name shape (Plan 02 lock) — there is NO attribute in the envelope that can carry a path. Destination path is resolved by SPT via Plan 01's info.json `cwd` resolver, never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice` joins under `tracked_root()/projects/<resolved_name>/` with no path-traversal escape. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with LLM round-trip) | mitigate | STALE-OVERWRITE MITIGATION (codex HIGH): `route_two_slice_monotonic` applies the `is_strict_subset` guard on the OUTBOUND echo_commune emit path. If the LLM emits a body that drops markers present on disk, the write is REJECTED and a WARN log surfaces. Inbound direct-route remains unconditional (it IS the new source of truth). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to defeat the subset guard (e.g. paraphrases markers so they look new) | accept | The subset guard is a heuristic, not a cryptographic check. An attacker controlling inbound content can already inject arbitrary prose into the project file — the route_two_slice surface trusts inbound by design. The guard protects against ACCIDENTAL LLM stale emit, not adversarial input. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes. Monotonic guard adds one extra disk read per write; negligible. |
  | T-25.3.04-R1 | Repudiation | Route failure goes unnoticed | mitigate | Structured WARN logs with stable prefix `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Codex MEDIUM resolution. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision (default Option 1).
  - Task 2 implements Option 1 with stale-overwrite mitigation; Task 3 skipped (Option 2 disfavored).
  - Task 4 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 5 phase-level invariant test asserts joint resolution of all four defects.
  - Task 6 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded (Option 1 default).
  - `route_inbound_commune_body` seam exists; unit-testable without LLM mocking.
  - `route_two_slice_monotonic` exists with `is_strict_subset` guard.
  - process_file_drop "commune" arm routes BEFORE LLM handoff.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS (Option 1 synchronous timing).
  - Stale-overwrite mitigation verified: next echo_commune fire CANNOT erase direct-routed inbound content.
  - Phase-level invariant test passes (joint resolution of all 4 defects).
  - All PowerShell — no Bash heredocs (codex LOW).
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 LOCKED default OR 2 with explicit user override rationale).
  - Plan 03 body-state-at-process_file_drop confirmation (from Task 0).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call).
  - Diff summary for `src/common/tracked.rs` (`route_two_slice_monotonic` + `is_strict_subset`).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content).
  - Stale-overwrite mitigation smoke verification.
  - Phase-level invariant test pass evidence.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task → verification.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 03, 02, 04 — in execution order) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist synchronously with stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Compare cycle-2 plans against your cycle-1 concerns. For each prior HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then provide a fresh per-plan review with severities (HIGH/MEDIUM/LOW). Mark severity bullets exactly as: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **Overall risk after revision**: LOW/MEDIUM/HIGH + one-line justification. Mention if convergence reached (no remaining HIGHs).

first_user_message:
  # Cross-AI Plan Review Request (Cycle 2 — Replan)

  These plans were revised after a Cycle 1 review surfaced 9 HIGH concerns. Verify whether HIGHs are resolved and whether new issues were introduced.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Prior Review (Cycle 1)

  ---
  phase: 25.3
  reviewers: [codex]
  reviewed_at: 2026-05-22T17:55:00-07:00
  plans_reviewed: [25.3-01-PLAN.md, 25.3-02-PLAN.md, 25.3-03-PLAN.md, 25.3-04-PLAN.md]
  ---

  # Cross-AI Plan Review — Phase 25.3

  ## Codex Review

  ## Summary

  The plans are thoughtful and mostly sequenced around the right defect boundaries: B2 first, D paired with B2, C before A. The strongest part is that they treat this as a contract problem across resolver, prompt, encoding, and persistence layers rather than a single missing write. The main weakness is that several plans still contain unresolved architectural choices, contradictory envelope shapes, and test/deploy steps that may fail on Windows or mutate scope mid-plan. I would not execute these as-is without tightening the contracts first.

  ## 25.3-01 Plan Review

  ### Strengths

  - Correctly isolates B2 as the smallest first fix.
  - Uses `last_project_name` as the canonical project source, which matches Phase 24.1.
  - Includes caller audits for `build_current_context_blocks` and `derive_current_repo_names`.
  - Adds regression coverage for `tracked` false-positive handling.

  ### Concerns

  - **HIGH:** `resolve_self_project_stamp` still falls back to plain cwd `stamp()`, which can still produce `project="tracked"` for outbound EVENT attrs. That preserves the same asymmetry the plan is trying to remove.
  - **MEDIUM:** Rejecting literal `"tracked"` globally can break a real project named `tracked`. Better reject it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback.
  - **MEDIUM:** Tests that mutate `SPT_HOME` may be flaky under parallel `cargo test`. They need serial execution or isolated env handling.
  - **MEDIUM:** The plan says the prompt is keyed on `claude_skill_owl`, but the described prompt format only has `CURRENT_PROJECT_CONTEXT:` with no visible project name. That makes smoke verification ambiguous.
  - **LOW:** `files_modified` lists `tests/native_owl.rs`, but the task says to put most tests in the private module inside `echo_commune.rs`.

  ### Suggestions

  - Filter `"tracked"` in all three resolver layers: tracked info, legacy perch field, and cwd fallback.
  - Add a helper returning `Option<String>` for “safe resolved project name” and make both EVENT stamping and context-block resolution use it.
  - Make env-mutating tests serial.
  - Add a debug/test-only way to verify the resolved project name used for the prompt, not just the body path.

  ### Risk Assessment

  **MEDIUM.** The core fix is sound, but the remaining cwd fallback can still emit the bad project name and preserve asymmetry.

  ## 25.3-02 Plan Review

  ### Strengths

  - Correctly identifies that `psyche.md` must teach absorption, not just emission.
  - Aligns `psyche.md`, `commune.md`, and `signoff/SKILL.md`.
  - Calls out that `psyche.md` is embedded and requires rebuild.

  ### Concerns

  - **HIGH:** Option B expands far beyond listed files. It requires `build_current_context_blocks`, `parse_two_slice`, `route_two_slice`, validation, and tests, but the plan only lists prompt/skill files.
  - **HIGH:** The plan alternates between “Psyche writes `projects/<name>/<self_id>.md`” and “Psyche emits envelopes that Rust routes.” That distinction matters. The LLM should not be taught that it directly writes the file unless it actually has that tool path.
  - **HIGH:** The must-haves require `<project-context name="...">`, but Option A explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved.
  - **MEDIUM:** Deploying this before the encoding contract is fixed may still leave the Psyche seeing `&lt;project-context&gt;`, making the new absorption rules ineffective.
  - **MEDIUM:** LLM behavior is only verified by smoke testing. There should also be static prompt-contract tests or golden prompt checks.

  ### Suggestions

  - Pick Option A or B before finalizing the plan. My recommendation: defer Option B unless you are ready to change parser/writer semantics in this same phase.
  - Reword psyche instructions as: “emit this envelope; SPT routes it to disk.”
  - If Option B is chosen, make it a separate Rust implementation plan, not a prompt-doc task.
  - Move this after Plan 03, or explicitly state that Plan 03’s decoded-at-LLM-boundary contract is assumed.

  ### Risk Assessment

  **HIGH.** The plan’s unresolved envelope-shape decision can cause incompatible implementations across Plans 03 and 04.

  ## 25.3-03 Plan Review

  ### Strengths

  - Correctly treats encoding as a full-chain contract problem.
  - Adds a missing `event_body_unescape` inverse and specifies amp-last order.
  - Requires an end-to-end regression for the observed mixed raw/encoded state.
  - Explicitly hands off the body-state answer to Plan 04.

  ### Concerns

  - **HIGH:** Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope string. Otherwise user body content can become structural-looking `<EVENT>` text in the wrong layer.
  - **HIGH:** `depends_on: []` is risky because this plan depends on the envelope shape chosen in Plan 02. Plan 02 and 03 cannot both make independent assumptions.
  - **MEDIUM:** “Decode at exactly ONE site” is too rigid. Attr decoding already exists, and multiple final consumers may need body decoding at their own boundary. The better invariant is “one body decode per transport traversal.”
  - **MEDIUM:** The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary test.
  - **MEDIUM:** Several commands are Bash-style (`tail`, heredoc) despite the project’s Windows-native constraint and PowerShell shell.
  - **LOW:** A large contract doc in `poll.rs` may age poorly if encoding behavior spans modules. A small `encoding.rs` module would be cleaner if churn is expected.

  ### Suggestions

  - Define the contract around parsed fields: attrs decoded as attrs, body decoded only after body extraction.
  - Add tests for literal `</EVENT>`, literal `<br>`, amp entities, and EVENT-PART split/reassembly.
  - Replace smoke commands with PowerShell-compatible here-strings and `Get-Content -Tail`.
  - Make Plan 03 precede Plan 02, or require Plan 02’s envelope decision as an input.

  ### Risk Assessment

  **MEDIUM-HIGH.** The investigation-first structure is good, but an incorrect decode boundary would create framing or prompt-injection regressions.

  ## 25.3-04 Plan Review

  ### Strengths

  - Correctly waits for Plan 03 before routing inbound bodies.
  - Makes the key architecture choice explicit: deterministic wrapper route vs LLM round-trip.
  - Preserves LLM handoff if direct routing is chosen.
  - Treats route failure as nonfatal, which protects live-agent continuity.

  ### Concerns

  - **HIGH:** Option 1 ignores Plan 02 Option B’s possible `name=` attr. If named project-context envelopes are adopted, routing by receiver `last_project_name` may write cross-project context to the wrong file.
  - **HIGH:** Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body. The plan acknowledges this but does not mitigate it.
  - **HIGH:** Option 2 relies on prompt compliance for persistence, which is the failure mode that triggered this phase. It is weaker than the defect requires unless paired with enforcement or retry.
  - **MEDIUM:** “Route failures are nonfatal” is operationally safe but can hide persistence failure. It needs a clear warning/status surface.
  - **MEDIUM:** Tests require mocking `resume_session_with_exit` and `route_two_slice`, but the plan does not call out the necessary dependency injection or helper extraction.
  - **LOW:** `$OWL deliver todlando <<'EOF'` is not PowerShell-compatible.

  ### Suggestions

  - Prefer Option 1, but add stale-overwrite mitigation: after direct route, ensure the next prompt baseline includes the routed content and warn/error if haiku output omits project context.
  - If Option B from Plan 02 is chosen, route by validated `name=` attr; otherwise route by resolved `last_project_name`.
  - Add a monotonic append/merge or source precedence rule so LLM output cannot erase direct-routed inbound content accidentally.
  - Extract a small testable helper for “route inbound commune body” rather than testing through full wrapper process flow.

  ### Risk Assessment

  **HIGH** unless Option 1 is chosen with overwrite mitigation. Option 2 is too dependent on LLM compliance for a persistence defect.

  ## Cross-Plan Recommendations

  1. Resolve envelope shape first: no-name `<project-context>` vs named `<project-context name="X">`. This decision affects Plans 02, 03, and 04.
  2. Recommended order: Plan 01, Plan 03, Plan 02, Plan 04. Encoding state should be settled before prompt absorption rules and wrapper routing.
  3. Avoid deploying after every partial plan unless you intentionally want intermediate behavior live. A single integrated deploy after 01-03, then final deploy after 04, would reduce churn.
  4. Replace Bash commands with PowerShell equivalents throughout.
  5. Fix cargo filter commands. `cargo test` accepts one test filter; commands like `cargo test --release --lib resolve_self_project_stamp_prefers... read_tracked_last_project_name` are likely invalid.
  6. Add one phase-level invariant test: inbound body with live + project envelopes reaches final persistence with no `tracked` project, no encoded tags, and marker text in the expected project file.

  Overall phase risk: **HIGH as written**, mainly due to unresolved cross-plan contract choices. With the envelope shape chosen upfront, resolver fallback tightened, and decode boundary precisely defined, this drops to **MEDIUM**.

  ## Revised Plans (Cycle 2)

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/common/owlery.rs
    - Cargo.toml
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-2 NOTE — codex review resolution map
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 01 section):
  #   HIGH (Plan 01): "resolve_self_project_stamp still falls back to plain cwd stamp(), which can still
  #     produce project='tracked' for outbound EVENT attrs. That preserves the same asymmetry the plan is
  #     trying to remove." → Task 1 + Task 2 (belt-and-braces 'tracked' filter at ALL THREE resolver layers:
  #     tracked-info, legacy perch field, cwd fallback).
  #   HIGH (locked decision #2 — NEW): "project name must derive from the cwd key in Self's perch info.json
  #     at owlery/<self_id>/info.json … resolve project-name from cwd via shared helper" → Task 0 audit +
  #     Task 1 new `project_name_from_cwd_path(&Path)` helper in owlery.rs + Task 2 wires it.
  #   MEDIUM: "Rejecting literal 'tracked' globally can break a real project named tracked. Better reject
  #     it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback."
  #     → Task 1: context-sensitive rejection (helper only filters when cwd resolves to the tracked psyche
  #     dir OR when the result came from the D-06 fallback branch). Tests cover the real-project-named-tracked
  #     non-regression case.
  #   MEDIUM: "Tests that mutate SPT_HOME may be flaky under parallel cargo test. They need serial execution
  #     or isolated env handling." → Task 1: add `serial_test` to Cargo.toml [dev-dependencies] and annotate
  #     env-mutating tests with `#[serial]`. The plan explicitly calls this out.
  #   MEDIUM: "The plan says the prompt is keyed on claude_skill_owl, but the described prompt format only
  #     has CURRENT_PROJECT_CONTEXT: with no visible project name. That makes smoke verification ambiguous."
  #     → Task 2: emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a test/debug-visible
  #     token, so smoke verification can grep the resolved name. (NB: this is purely a header annotation;
  #     LLM-side teaching in Plan 02 will be free to read or ignore the parenthetical.)
  #   LOW: "files_modified lists tests/native_owl.rs, but the task says to put most tests in the private
  #     module inside echo_commune.rs." → manifest fixed: tests live in the existing `#[cfg(test)] mod tests`
  #     blocks inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081) — no entry added to
  #     `tests/native_owl.rs`. files_modified updated.

  must_haves:
    truths:
      - "When Self's perch info.json `cwd` key resolves (via the new owlery helper) to a path inside `claude_skill_owl/`, `build_current_context_blocks` resolves the project to `claude_skill_owl` (NOT `tracked`, NOT psyche_dir basename, NOT raw process cwd)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` is rejected as a project name AT ALL THREE resolver layers (info.json `cwd`-derived → tracked-agents `last_project_name` → cwd `derive_current_repo_names` D-06 fallback) when the source path is the SPT tracked psyche dir, while real user projects literally named `tracked` continue to work (rejection is path-context-sensitive, not literal-global)."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new resolver used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via `build_current_context_blocks` (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper (cwd-path → project name, mirrors `derive_current_repo_names()` resolution order but takes an explicit Path)."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-cwd-derived project resolution + belt-and-braces `tracked` rejection at all three resolver layers."
        contains: "project_name_from_cwd_path"
      - path: "Cargo.toml"
        provides: "`serial_test` dev-dependency added so env-mutating tests can run serially under parallel `cargo test`."
        contains: "serial_test"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "reads `cwd` key from Self's perch info.json (Phase 26 D5/Q1 field), passes Path to shared resolver"
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "same path: read info.json cwd → resolver. Symmetry guarantee."
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md` per LOCKED DECISIONS:

  1. **Locked decision #2:** project name MUST derive from the `cwd` key in Self's perch `info.json` at `$SPT_HOME/owlery/<self_id>/info.json` (the `InfoJson.cwd` field — Phase 26 D5/Q1, `src/common/types.rs:110`). NOT from `tracked_agent_info.last_project_name` directly; NOT from process-global `std::env::current_dir()`. The cwd-key path is authoritative; downstream helpers resolve the project name from that path via shared logic.

  2. **Codex HIGH (belt-and-braces):** `"tracked"` must be filtered at ALL THREE resolver layers — info.json-cwd-derived, legacy perch field, and the cwd D-06 fallback inside `derive_current_repo_names()` consumers — but ONLY when the source path is the SPT tracked psyche dir, so a real user project literally named `tracked` continues to work.

  The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) currently calls `owlery::derive_current_repo_names()`, which reads process-global `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so the D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and `route_two_slice` writes to `projects/tracked/<self_id>.md` (or drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  Meanwhile the same fire pass at line 869 calls `resolve_self_project_stamp(self_id)` which is SUPPOSED to read Self's project from info.json (lines 110-127) but reads the unused legacy perch `project` field and falls through to the same broken cwd stamp.

  **Investigation needed (Task 0):** confirm whether `project_name_from_cwd_path(&Path)` already exists in `src/common/owlery.rs` under some name. From the grep audit (planner side), no such helper exists — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)` (path-based, but private and not exposed). Task 0 explicitly audits + reports findings before Task 1 creates the helper. If a helper does exist, Task 1 reuses it.

  This plan:
  1. (Task 0) Audits `src/common/owlery.rs` for any existing cwd-path → project-name helper. Investigation only.
  2. (Task 1) Creates `project_name_from_cwd_path(path: &Path) -> Option<String>` in `owlery.rs` mirroring the resolution order of `derive_current_repo_names()` but taking an explicit Path. Refactors `resolve_self_project_stamp` to consume it (read perch info.json `cwd` → call helper). Adds belt-and-braces `"tracked"` rejection that fires ONLY when the source path is the SPT tracked psyche dir.
  3. (Task 2) Rewires `build_current_context_blocks` to consult the corrected resolver (same path as `resolve_self_project_stamp`) and emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a debug/test-visible token.
  4. (Task 3) Audits other callers of `build_current_context_blocks` (signoff prompt-build path) to confirm they pick up the fix transparently. Runs `cargo build --release` as the build-witness; defers smoke verification to Plan 02 (which lands psyche.md absorption + deploys).

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/common/owlery.rs` — new `project_name_from_cwd_path` helper + made `find_git_root_basename` visible (pub(crate)) if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new helper.
  - `Cargo.toml` — `serial_test` dev-dependency added.
  - Unit tests in the existing `#[cfg(test)] mod tests` blocks in `src/owl/echo_commune.rs` and `src/common/owlery.rs`.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers extracted from src/common/owlery.rs + src/common/types.rs. -->

  From src/common/types.rs (lines 104-110, Phase 26 D5/Q1):
  ```rust
  pub struct InfoJson {
      // ... other fields
      /// Phase 26 D5/Q1: working directory the perch was created in.
      /// Used by the no-arg `/spt:live` picker for cross-repo collision detection
      /// (`pick-spec --resolve <name>`). Forward-slash-normalized for cross-platform
      /// stability. `None` on perches created before Phase 26 ships — treated as
      /// "unknown repo" by the owlery scan.
      #[serde(skip_serializing_if = "Option::is_none")]
      pub cwd: Option<String>,
      // ... other fields
  }
  ```

  From src/common/owlery.rs (line 204):
  ```rust
  pub fn info_file(id: &str) -> PathBuf;
  // $SPT_HOME/owlery/<id>/info.json — Self's PERCH info.json (NOT the tracked-agents one).
  // This is the one carrying the `cwd` field per locked decision #2.
  ```

  From src/common/owlery.rs (line 792, currently PRIVATE):
  ```rust
  fn find_git_root_basename(start: &std::path::Path) -> Option<String>;
  // Walks parents from `start` looking for `.git`; returns basename of the containing directory.
  // Already-correct primitive for the cwd→project resolver. Task 1 makes this `pub(crate)` so
  // the new public helper can reuse it without duplicating the walk.
  ```

  From src/common/owlery.rs (line 763):
  ```rust
  pub fn derive_current_repo_names() -> Vec<String>;
  // Process-global cwd resolver. Order: (a) find_git_root_basename(env::current_dir())
  // (b) git remote get-url origin basename (c) D-06 fallback to cwd.file_name().
  // The new helper mirrors (a)+(c) but accepts an explicit Path — same return shape (Option<String>).
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written → always returns None → falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today; will resolve correctly after Task 1.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Audit owlery.rs for any existing cwd-path → project-name helper</name>
    <files>(read-only investigation; no edits)</files>
    <action>
      Run greps:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub fn project_name_from|fn derive_project|fn project_name_for|fn project_for_path' -SimpleMatch
      Select-String -Path src/common/*.rs -Pattern 'fn .*project.*_from_|fn .*project_name' -SimpleMatch
      ```
      Also grep across `src/common/` for any helper that takes `&Path` and returns `Option<String>` describing a project name.

      Document findings inline in the SUMMARY:
      - If a helper exists with matching semantics → Task 1 reuses it (record file:line).
      - If `find_git_root_basename` is the closest thing → Task 1 builds the new public helper around it.
      - If nothing exists → Task 1 creates from scratch.

      Decision rule (no checkpoint required — autonomous): if the audit finds a helper that already accepts &Path and resolves project name with at least the (a)+(c) resolution order from `derive_current_repo_names()`, reuse it verbatim and name it as such in Task 1. Otherwise create `project_name_from_cwd_path` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd' | Measure-Object | Select-Object -ExpandProperty Count</automated>
    </verify>
    <done>
      - Audit complete; helper inventory recorded in SUMMARY.
      - Task 1 has an unambiguous decision: reuse-existing OR create-new.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 1: Create project_name_from_cwd_path helper + refactor resolve_self_project_stamp + add serial_test dep</name>
    <files>src/common/owlery.rs, src/owl/echo_commune.rs, Cargo.toml</files>
    <behavior>
      - `project_name_from_cwd_path(Path::new("C:/Users/decid/Documents/projects/claude_skill_owl/some/sub"))` returns `Some("claude_skill_owl")` (resolves via parent walk to the `.git` ancestor at `claude_skill_owl/`).
      - `project_name_from_cwd_path(Path::new("$SPT_HOME/psyches/tracked"))` returns `None` (path-context-sensitive `tracked` rejection — the path resolves via D-06 fallback to basename `tracked` AND the path equals `crate::common::owlery::psyche_dir()`; reject only in that specific case).
      - `project_name_from_cwd_path(Path::new("/home/user/projects/tracked"))` where `tracked/` IS a real `.git` repo returns `Some("tracked")` (real-project-named-tracked non-regression).
      - `project_name_from_cwd_path(Path::new("/nonexistent/path"))` returns `None` gracefully (no panic).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns a stamp with `project = "claude_skill_owl"` (NOT `tracked`).
      - `resolve_self_project_stamp("todlando")` when info.json is missing OR `cwd` field is None → falls through to legacy perch field → finally falls through to `crate::common::git::stamp()` cwd-based — BUT with the same `tracked` rejection guard wrapped around the final fallback (belt-and-braces).
      - Real-project-named-tracked: if a user genuinely has a repo named `tracked` and their process cwd is inside it, `stamp()` returns `project = "tracked"` AND the post-stamp guard does NOT strip it (because the guard only fires when the resolved path equals psyche_dir, not when the name equals literal `"tracked"`).
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version — check what other plans pin) under `[dev-dependencies]`. Tests in Task 1 + Task 2 that mutate `SPT_HOME` will be annotated with `#[serial]`.

      Step 2 — `src/common/owlery.rs`: make `find_git_root_basename` `pub(crate)` (currently private at line 792). Add a new public helper directly below it (around line 800):

      ```rust
      /// Path-based cwd → project name resolver. Mirrors `derive_current_repo_names()`
      /// (D-03 (a) + (c)) but takes an EXPLICIT `&Path` instead of reading process-global
      /// `std::env::current_dir()`.
      ///
      /// Resolution order:
      ///   (a) Walk parents from `path` looking for `.git`; if found, return that
      ///       directory's file_name() as the local basename. (Uses `find_git_root_basename`.)
      ///   (b) If no `.git` is found, fall back to `path.file_name()` as the basename
      ///       (D-06 fallback equivalent).
      ///   (c) Path-context-sensitive `tracked` rejection (25.3-01 Defect B2): if the
      ///       resolved name is the literal `"tracked"` AND `path` canonicalizes to the
      ///       SPT tracked psyche dir (`psyche_dir()`), return None. Real user projects
      ///       literally named `tracked` are NOT rejected (their resolved path lies
      ///       outside psyche_dir).
      ///
      /// Returns None when `path` has no file_name component (filesystem root).
      pub fn project_name_from_cwd_path(path: &std::path::Path) -> Option<String> {
          let name = find_git_root_basename(path)
              .or_else(|| path.file_name().and_then(|s| s.to_str()).map(String::from))?;
          // 25.3-01 Defect B2: path-context-sensitive `tracked` rejection.
          // Real user projects literally named "tracked" are NOT rejected — only the
          // SPT psyche_dir basename collision is filtered.
          if name == "tracked" && is_within_tracked_psyche_dir(path) {
              return None;
          }
          Some(name)
      }

      /// Helper: returns true iff `path` (after canonicalization, best-effort)
      /// is the SPT tracked psyche dir or a child of it. Used by
      /// `project_name_from_cwd_path` to reject the D-06 false positive without
      /// rejecting real user projects literally named "tracked".
      fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool {
          let psyche = psyche_dir();  // existing helper; returns $SPT_HOME/psyches/tracked
          let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
          let psyche_canon = psyche.canonicalize().unwrap_or(psyche);
          path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)
      }
      ```

      Step 3 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127):

      ```rust
      fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp {
          let mut s = crate::common::git::stamp();

          // 25.3-01 Defect B2 (locked decision #2): resolve project from Self's
          // perch info.json `cwd` key (Phase 26 D5/Q1) via the shared helper.
          // This is the AUTHORITATIVE source. Legacy perch `project` field and
          // process cwd are fallbacks only.
          if let Some(name) = resolve_self_project_name_via_info_cwd(self_id) {
              s.project = name;
              return s;
          }

          // Legacy fallback (preserved for forward compat — if a future phase
          // populates the perch `project` field, this path still works).
          if let Some(p) = read_self_project_from_info(self_id) {
              if !p.is_empty() && p != "tracked" {
                  // Belt-and-braces: filter literal "tracked" here too.
                  // Real-project-named-tracked is acceptable collateral at this
                  // layer because the legacy perch `project` field is never
                  // written by any current code path; if a future phase writes
                  // it, that phase is responsible for the value.
                  s.project = p;
                  return s;
              }
          }

          // Final fallback: cwd-relative stamp. Belt-and-braces filter: if the
          // stamp's project is "tracked" AND we can verify via process cwd that
          // we're inside psyche_dir, leave s.project as a NON-project sentinel.
          // BUT preserve real-project-named-tracked: only filter when the
          // current process cwd is psyche_dir.
          if s.project == "tracked" {
              if let Ok(cwd) = std::env::current_dir() {
                  if crate::common::owlery::project_name_from_cwd_path(&cwd).is_none() {
                      // Helper agrees this is the D-06 false positive; clear it.
                      s.project = String::new();
                  }
              }
          }
          s
      }

      /// 25.3-01 Defect B2 (locked decision #2): primary resolver for Self's
      /// project name. Reads Self's perch info.json (Phase 26 `cwd` field),
      /// passes the path to the shared `project_name_from_cwd_path` resolver.
      fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String> {
          let info_path = crate::common::owlery::info_file(self_id);
          let content = std::fs::read_to_string(&info_path).ok()?;
          let info: crate::common::types::InfoJson =
              serde_json::from_str(&content).ok()?;
          let cwd_str = info.cwd?;
          let cwd_path = std::path::PathBuf::from(&cwd_str);
          crate::common::owlery::project_name_from_cwd_path(&cwd_path)
      }
      ```

      Remove the TODO comment at lines 108-109 (now fulfilled).

      Step 4 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s) inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081 — choose whichever is the resolver-/info-shaped block; if no fit, add to the line-1127 block). Tests in `src/common/owlery.rs` go in that file's existing `mod tests`.

      Mark every test that mutates `SPT_HOME` with `#[serial_test::serial]`.

      Tests:
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — create temp dir with `.git/` marker; assert helper returns the dir's basename.
      - `owlery::tests::project_name_from_cwd_path_falls_back_to_basename_when_no_git` — temp dir without `.git`; assert helper returns the basename.
      - `owlery::tests::project_name_from_cwd_path_rejects_psyche_dir_basename` — set `SPT_HOME` to a temp; pass `psyche_dir()` as path; assert returns None. `#[serial]`.
      - `owlery::tests::project_name_from_cwd_path_accepts_real_project_named_tracked` — temp dir literally named `tracked/.git/`; assert helper returns Some("tracked"). `#[serial]` (sets SPT_HOME to a different temp to ensure psyche_dir does NOT equal the project path).
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write a fake perch info.json with cwd pointing to a temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_falls_through_to_legacy_when_cwd_missing` — info.json without `cwd` field; assert legacy path is consulted.
      - `echo_commune::tests::resolve_self_project_stamp_clears_tracked_at_final_fallback` — info.json missing entirely, process cwd is psyche_dir → assert `stamp.project` is empty (NOT `tracked`). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_preserves_real_project_named_tracked` — info.json missing, process cwd is a temp dir literally named `tracked/.git/` OUTSIDE psyche_dir → assert `stamp.project == "tracked"`. `#[serial]`.

      Build witness: `cargo build --release` at end of task.
    </action>
    <verify>
      <automated>cargo test --release project_name_from_cwd_path 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, pub.
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` reads info.json `cwd` first via `resolve_self_project_name_via_info_cwd`.
      - All three resolver layers (info-cwd, legacy perch field, final cwd-stamp) filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces).
      - Real-project-named-tracked test passes (regression guard).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - 8 unit tests pass; `#[serial]` annotations present on env-mutating ones.
      - `cargo build --release` succeeds.
      - TODO at echo_commune.rs:108-109 removed.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks + emit debug-visible project name token</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked when Self's perch info.json `cwd` resolves to `claude_skill_owl/` produces a prompt containing `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` followed by the content of `projects/claude_skill_owl/todlando.md` (or first-commune literal).
      - Same call when info.json `cwd` is missing AND process cwd resolves to psyche_dir (D-06 returns "tracked") produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block.
      - Same call when info.json `cwd` is missing AND process cwd resolves to a real repo (e.g. `my_real_repo/`) produces a prompt with `CURRENT_PROJECT_CONTEXT (name=my_real_repo):` keyed on `my_real_repo`.
      - Outbound EVENT stamping at line 869 and inner-block resolution at line 411 BOTH consume the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). No asymmetry.
      - The `(name=<X>)` parenthetical is purely a debug/test-visible token — Plan 02 will teach the LLM how to consume it (or to ignore it).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 360-425.

      Replace the project resolution at line 411 with the same layered resolver used by `resolve_self_project_stamp`:

      ```rust
      pub(crate) fn build_current_context_blocks(self_id: &str) -> String {
          let live_path = owlery::agent_worktree_path(self_id).join("live_context.md");
          let live_body = std::fs::read_to_string(&live_path)
              .unwrap_or_else(|_| "(none — first commune)".to_string());

          // 25.3-01 Defect B2 (locked decision #2): authoritative resolver
          // (info.json cwd → project_name_from_cwd_path). Symmetric with
          // resolve_self_project_stamp at line 869.
          let resolved_project: Option<String> = resolve_self_project_name_via_info_cwd(self_id)
              .or_else(|| {
                  // Legacy/process-cwd fallback (kept for callers like
                  // signoff prompt-build that may run from a real project cwd).
                  owlery::derive_current_repo_names().into_iter().next().and_then(|name| {
                      // Belt-and-braces (codex HIGH): filter literal "tracked"
                      // here too, but only when the process cwd resolves to
                      // psyche_dir.
                      if name == "tracked" {
                          let cwd = std::env::current_dir().ok()?;
                          if owlery::project_name_from_cwd_path(&cwd).is_none() {
                              return None;
                          }
                      }
                      Some(name)
                  })
              });

          let project_block = match resolved_project {
              None => String::new(),
              Some(name) => {
                  let proj_path =
                      owlery::project_worktree_path(&name).join(format!("{}.md", self_id));
                  let body = std::fs::read_to_string(&proj_path)
                      .unwrap_or_else(|_| "(none — first commune in project)".to_string());
                  // Codex MEDIUM: debug/test-visible name token in the header.
                  // Plan 02 will teach the LLM how to consume or ignore the
                  // parenthetical.
                  format!("\n\nCURRENT_PROJECT_CONTEXT (name={}):\n{}", name, body)
              }
          };

          format!("CURRENT_LIVE_CONTEXT:\n{}{}", live_body, project_block)
      }
      ```

      Update the doc comment on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (info-cwd → process-cwd-with-tracked-filter → omit).
      - Keep the existing Plan 04 Task 4 DRY note about signoff prompt builder.
      - Add: "Outbound EVENT stamping at run_echo_commune line 869 (`resolve_self_project_stamp`) consumes the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). The header's `(name=<X>)` token is debug/test-visible and is the SAME `X` that appears in the outbound `<EVENT project=\"X\">` attr in the same fire pass — assert this symmetry in tests."

      Unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_info_json_cwd_for_project` — write fake perch info.json with cwd pointing to a temp .git repo; assert prompt contains `CURRENT_PROJECT_CONTEXT (name=<repo_basename>):`. `#[serial]`.
      - `build_current_context_blocks_omits_block_when_info_cwd_resolves_to_psyche_dir` — info.json `cwd = psyche_dir`; assert NO `CURRENT_PROJECT_CONTEXT` substring. `#[serial]`.
      - `build_current_context_blocks_emits_name_token_in_header` — assert the exact substring `CURRENT_PROJECT_CONTEXT (name=` appears when a project is resolved. `#[serial]`.
      - `build_current_context_blocks_first_commune_literal_when_project_file_missing` — info.json resolves project but `projects/<name>/<self_id>.md` is absent; assert body is `(none — first commune in project)`. `#[serial]`.
      - `build_current_context_blocks_and_resolve_self_project_stamp_agree` — same fire-pass invariant: both call sites return the SAME project name string. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `resolve_self_project_name_via_info_cwd` first.
      - Header format is `CURRENT_PROJECT_CONTEXT (name=<X>):` when project resolves.
      - Belt-and-braces `tracked` filter on the process-cwd fallback layer.
      - 5 unit tests pass, all `#[serial]`-annotated.
      - Doc comment updated with Defect B2 attribution + symmetry invariant.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Caller audit + binary rebuild (smoke deferred to Plan 02 deploy)</name>
    <files>(read-only audit; build witness only)</files>
    <action>
      Step 1 — Grep audit (PowerShell):
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'build_current_context_blocks|derive_current_repo_names|resolve_self_project_stamp' -SimpleMatch
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder — confirm it calls `build_current_context_blocks` (transparently fixed) or has its own resolver (port the fix).
      - Outbound EVENT stamping (echo_commune.rs:869) — fixed in Task 1.
      - Anything else — investigate.

      Step 2 — Read `src/live/signoff.rs::compose_init_signoff_payload`. Confirm it invokes `build_current_context_blocks(self_id)` and does NOT have a divergent project-resolution path. If divergent, port Task 2 logic OR refactor to call `build_current_context_blocks`. Document the path taken in SUMMARY.

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site. For each, classify as:
      (a) Not a haiku/signoff prompt builder (D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way, OR
      (c) NEEDS the same fix → flag in SUMMARY for follow-up Plan if any.

      Step 4 — Rebuild the binary:
      ```powershell
      cargo build --release
      ```
      Captures binary witness only. Smoke verification (does the artifact materialize?) is DEFERRED to Plan 02, which lands psyche.md absorption rules and performs the deploy.

      Step 5 — Write a one-paragraph "Plan 02 handoff note" in the SUMMARY stating: "After this plan, the haiku prompt is keyed on the correct project name, but the haiku LLM may still drop the `<project-context>` envelope (Defect D). Full end-to-end artifact materialization requires Plan 02."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Caller audit complete; SUMMARY documents each `build_current_context_blocks` and `derive_current_repo_names` site with disposition.
      - `cargo build --release` succeeds.
      - Plan 02 handoff note written.
      - NO deploy in this plan (per cross-plan recommendation: single integrated deploy after Plans 01+03).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Self's perch info.json read (`InfoJson.cwd` field) | Filesystem read of JSON the SPT runtime itself writes at perch creation time. |
  | Process cwd read (fallback layer) | Process-global state; safe for our own process. |
  | Haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `cwd` field in Self's perch info.json | accept | Field is written by SPT at perch creation (Phase 26 D5/Q1) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. The path is consumed read-only — never executed, never joined into a filesystem write destination by this plan. |
  | T-25.3.01-I1 | Information disclosure | Project name (resolved from cwd path) leaked into haiku prompt | accept | Same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed perch info.json crashes the resolver | mitigate | `resolve_self_project_name_via_info_cwd` returns Option via `.ok()?` chain; JSON parse failures → None → falls through to legacy/cwd layers. No panic path. Tested via `info_json_malformed_falls_through` (add if missing). |
  | T-25.3.01-T2 | Tampering | `cwd` field set to a path containing a poison basename (e.g. `..`, `/`, or shell metachars) | mitigate | `project_name_from_cwd_path` uses `Path::file_name()` (returns last normalized component) and `find_git_root_basename` (walks parents); both are pure string/path manipulations — no shell evaluation, no exec. The returned name flows ONLY into `project_worktree_path(&name)` which already joins under `tracked_root()/projects/` with no path traversal escape. |
  | T-25.3.01-T3 | Tampering | Real-project-named-tracked confusion (user has legitimate `tracked` repo) | mitigate | Path-context-sensitive rejection: filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit test `project_name_from_cwd_path_accepts_real_project_named_tracked` guards this. |
  | T-25.3.01-SC | Tampering (supply chain) | `serial_test` crate (new dev-dep) | mitigate | `serial_test` is a widely-used crate (millions of downloads on crates.io). RESEARCH.md package audit not strictly required for dev-only deps, but: verify via `https://crates.io/crates/serial_test` before adding. Pin to a major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW) is the perch-info-driven entry point.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback, with `tracked` filter.
     - All four layers are exercised by unit tests.

  2. Grep audit complete: both `build_current_context_blocks` and `derive_current_repo_names` callers documented.

  3. Binary build witness: `cargo build --release` succeeds.

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).

  5. `serial_test` dev-dep added; env-mutating tests annotated `#[serial]`.
  </verification>

  <success_criteria>
  - `project_name_from_cwd_path(path)` resolves the correct project name when called with Self's perch `cwd` path.
  - `resolve_self_project_stamp("todlando")` returns `project="claude_skill_owl"` when info.json `cwd` is set correctly.
  - `build_current_context_blocks("todlando")` emits `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` (debug-visible token).
  - Belt-and-braces `tracked` filter at all three resolver layers; real-project-named-tracked non-regression preserved.
  - `cargo test --release` passes including 13 new tests; no regressions.
  - `cargo build --release` succeeds.
  - NO deploy from this plan (deferred to Plan 02).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Task 0 audit findings (existing helper or none).
  - Resolver layer diagram (info-cwd → legacy-perch → cwd-stamp), each with its `tracked` filter point.
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note.
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 3
  depends_on:
    - 25.3-01
    - 25.3-03
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 3 (was wave 2), AFTER 03. Absorption rules
  # can only be written authoritatively once we know the body-state at LLM-stdin (Plan 03 contract).
  #
  # This plan addresses the following codex HIGH/MEDIUM concerns from 25.3-REVIEWS.md (Plan 02 section):
  #   HIGH (locked decision #1): "The must-haves require `<project-context name='...'>`, but Option A
  #     explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved."
  #     → RESOLVED. Envelope shape is LOCKED to Option A: no-name `<project-context>`. NO `name=` attr
  #     anywhere in this plan. All occurrences updated. Producer/consumer envelope shape contract is
  #     unchanged from pre-25.3.
  #   HIGH: "Option B expands far beyond listed files. It requires build_current_context_blocks,
  #     parse_two_slice, route_two_slice, validation, and tests, but the plan only lists prompt/skill files."
  #     → RESOLVED by Option A lock. parse_two_slice / route_two_slice are NOT modified. build_current_context_blocks
  #     is changed by Plan 01 (header annotation only, not envelope contract). Files in this plan are strictly
  #     psyche.md + skill files.
  #   HIGH: "The plan alternates between 'Psyche writes projects/<name>/<self_id>.md' and 'Psyche emits
  #     envelopes that Rust routes.' That distinction matters. The LLM should not be taught that it directly
  #     writes the file unless it actually has that tool path." → Task 2 reworded throughout: "emit this
  #     envelope; SPT routes it to disk." Psyche's job = emit. SPT's job = route. No path-writing teaching.
  #   HIGH (resolved by reorder): "depends on Plan 03's encoding contract for body-state at LLM-stdin
  #     boundary." → `depends_on: [25.3-01, 25.3-03]`. Task 0 reads Plan 03 SUMMARY to lift the body-state
  #     answer into psyche.md.
  #   MEDIUM: "Deploying this before the encoding contract is fixed may still leave the Psyche seeing
  #     `&lt;project-context&gt;`, making the new absorption rules ineffective." → RESOLVED by wave order.
  #     This plan deploys ONLY AFTER 03 lands; Plan 03's encoding fix ships in the same deploy.
  #   MEDIUM: "LLM behavior is only verified by smoke testing. There should also be static prompt-contract
  #     tests or golden prompt checks." → Task 2 Step 4 adds a golden prompt check: snapshot the `<absorption>`
  #     section content; test verifies psyche.md contains the canonical block (line count + key substrings).
  #     Not a full LLM-output golden, but a static prompt-contract check.

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context>...</project-context>` (Option A locked shape — NO `name=` attr), it knows from psyche.md that it should merge the body into its in-memory project context and re-emit on the next context-save fire. SPT (Rust) routes the body to `projects/<name>/<self_id>.md`; the LLM is NOT taught any filesystem path."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The Psyche LLM does NOT attempt to derive or re-emit a project name token — `<project-context>` is no-name throughout (Option A locked). SPT (build_current_context_blocks per Plan 01) handles all project-name resolution."
      - "`plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` reference the same Option-A no-name `<project-context>` shape and use the phrasing 'emit this envelope; SPT routes it to disk' (NOT 'Psyche writes the file')."
      - "After Plan 03's encoding fix + this plan's psyche.md absorption rules + consolidated deploy, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
      - "psyche.md absorption rules state explicitly that they assume the Plan 03 encoding contract (body arrives at LLM-stdin in <encoded|decoded> state per Plan 03 SUMMARY's chosen Option) — so future editors can't accidentally re-encode."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New `<absorption>` section using Option A no-name shape and 'SPT routes to disk' phrasing."
        contains: "<project-context>"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (no-name shape; 'SPT routes' phrasing)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM emit instruction)"
        to: "src/owl/echo_commune.rs::route_two_slice"
        via: "no-name `<project-context>` envelope; Rust resolves destination project via Plan 01's resolver"
        pattern: "project-context"
      - from: "psyche.md absorption rules"
        to: "src/owl/poll.rs (Plan 03 encoding contract)"
        via: "body-state-at-LLM-stdin assumption (encoded or decoded per Plan 03 chosen Option)"
        pattern: "ENCODING CONTRACT"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms existing teaching (lines 244-354) covers both envelopes, but TWO gaps remain:

  1. **Absorption side gap (the primary fix):** Teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context>...</project-context>`, treat its body as project-context delta and merge it into your in-memory context." The architecture relies on round-trip-via-LLM — the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk via SPT routing. When inbound envelopes are silently ignored, content is lost.

  2. **Routing-clarity gap (codex HIGH):** Existing teaching is ambiguous about WHO writes the file. The corrected teaching is: "**emit this envelope; SPT routes it to disk**" — the LLM's job is EMIT, SPT's job is ROUTE. The LLM is not taught any filesystem path; route_two_slice (Plan 25 Plan 04) is the routing surface, not the LLM.

  Per LOCKED DECISIONS:
  - **Locked decision #1:** envelope shape is Option A (no-name `<project-context>`). NO `name=` attr anywhere. Producer/consumer envelope shape contract is UNCHANGED from pre-25.3. Minimal blast radius — no parser/writer changes.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (body-state-at-LLM-stdin). This plan's absorption rules reference that contract by name so future editors don't accidentally re-encode.
  - **Consolidated deploy:** This plan owns the integrated deploy of Plans 01+03+02 (single DEPLOY.ps1 invocation). Plan 04 has its own subsequent deploy.

  This plan cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync via DEPLOY; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` — strengthened absorption rules + Option A no-name shape + "SPT routes" phrasing + Plan 03 contract reference.
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - Binary rebuild + DEPLOY.ps1 -Bump patch (single consolidated deploy for Plans 01+03+02).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after the deploy.

  Purpose: Plan 01 made the haiku see the right project name. Plan 03 made the body uniformly decoded at LLM stdin. Plan 02 makes the haiku LLM actually emit `<project-context>` AND merge inbound. Without all three, the artifact never materializes.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO CURRENT_PROJECT_CONTEXT block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — STALE PHRASING; replace.
  - `<context_save>` section (line 325-355) reinforces emit-every-3-5-messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section teaching the LLM to detect and merge INBOUND `<project-context>` envelopes (no-name shape).
  - No rule that says "SPT routes the emit to disk; you do not name the project or know any path."
  - No reference to the Plan 03 encoding contract (body-state-at-LLM-stdin).

  From Plan 01 outcome (already landed by execution time):
  - `build_current_context_blocks` emits `CURRENT_PROJECT_CONTEXT (name=<X>):` header. The `(name=<X>)` is DEBUG/test-visible only — the LLM does NOT need to read it for routing purposes (SPT handles routing). The LLM MAY note it as context but MUST NOT emit it as a `name=` attr in `<project-context>` (Option A locked: no name attr).

  From Plan 03 outcome (already landed by execution time, read SUMMARY):
  - Body state at LLM stdin = <encoded|decoded> per chosen Option (Plan 03 Task 2). This plan's psyche.md absorption rules reference that state by name.

  From Plan 25 envelope code (UNCHANGED in this plan):
  - `parse_two_slice` reads literal `<live-context>` / `<project-context>` tags (no-name shape).
  - `route_two_slice` writes to `projects/<resolved_name>/<self_id>.md`, resolving `<resolved_name>` via Plan 01's helper at write time. The LLM never names the project.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 01 + Plan 03 SUMMARYs and extract handoff contracts</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` and `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract for the psyche.md edits:
      - From Plan 01: the header format that `build_current_context_blocks` now emits — exactly `CURRENT_PROJECT_CONTEXT (name=<X>):` per Plan 01 Task 2. psyche.md absorption rule will instruct the LLM: "the parenthetical `(name=<X>)` is purely informational; you MUST NOT echo it back as a `name=` attr."
      - From Plan 03: the body state at LLM stdin (encoded vs decoded). psyche.md will state this explicitly so future editors don't re-encode. The exact wording goes into a new "Encoding assumption" subsection of `<absorption>`.

      Record both findings inline in this plan's SUMMARY for traceability.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md; Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 01 header format extracted.
      - Plan 03 body-state-at-LLM-stdin extracted.
      - Both recorded in this plan's SUMMARY for downstream traceability.
    </done>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md (Option A locked; 'SPT routes' phrasing)</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. **Live-context absorption:** When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. **Project-context absorption:** When an inbound payload contains `<project-context>...</project-context>` (NO name attr — locked Option A shape), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>`.

      3. **Inbound merge is always active.** You do not need a CURRENT_LIVE_CONTEXT or CURRENT_PROJECT_CONTEXT prompt block to absorb inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. **SPT routes; you emit.** When you emit a `<project-context>...</project-context>` envelope, SPT (the Rust runtime) writes the body to `projects/<resolved_project>/<self_id>.md` and resolves `<resolved_project>` via Self's perch info.json `cwd` field (25.3-01 Defect B2 fix). You do NOT name the project; you do NOT know the filesystem path; the envelope shape is no-name throughout. If the prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header carries an `(name=<X>)` parenthetical, treat it as informational only — DO NOT echo it as a `name=` attr.

      5. **Encoding assumption (25.3-03 contract reference).** At the LLM-stdin boundary, the EVENT envelope body is in <STATE> form per Plan 03's chosen Option (read 25.3-03-SUMMARY.md Plan-04-handoff section; insert the chosen state literal here at execute time — typically "decoded" so `<live-context>` and `<project-context>` are LITERAL tags). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume.

      Update the existing `<output_envelope>` rule at psyche.md line 322 — remove the `<cwd_project>` phrasing entirely and replace with: "Receivers split the two-slice output by tag name (`<live-context>` and `<project-context>`); SPT resolves the destination project via Self's perch info.json (25.3-01 fix) — the LLM does NOT emit a project name."

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions; ensure emit rule is "emit `<project-context>` whenever a CURRENT_PROJECT_CONTEXT block (with or without name parenthetical) is present in the prompt".

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching + Option A shape clarification + encoding assumption.

      Keep total prompt growth under +70 lines (psyche.md is context-window-critical for every Psyche session).

      Step 4 — Static prompt-contract check (codex MEDIUM). Add a tiny test in `tests/native_owl.rs` (or `tests/psyche_md_contract.rs`):
      ```rust
      #[test]
      fn psyche_md_contains_25_3_absorption_section() {
          let content = include_str!("../psyche.md");
          assert!(content.contains("<absorption>"), "psyche.md must have <absorption> section");
          assert!(content.contains("Absorbing inbound two-slice envelopes"), "section title missing");
          assert!(content.contains("SPT routes"), "must teach 'SPT routes' phrasing (codex HIGH)");
          assert!(!content.contains("<project-context name="), "Option A locked: no name= attr");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("encoding contract"), "must reference Plan 03 contract");
      }
      ```
      This is a static prompt-contract test — not a full LLM-output golden, but it guards against accidental phrase drift in future edits.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'project-context' | Measure-Object).Count; (Select-String -Path psyche.md -Pattern 'live-context' | Measure-Object).Count; (Get-Content psyche.md | Measure-Object -Line).Lines; cargo test --release psyche_md_contains_25_3_absorption_section 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-5.
      - Option A no-name shape used throughout — NO `name=` attr anywhere.
      - "SPT routes; you emit" phrasing present (codex HIGH).
      - Encoding assumption references Plan 03 (codex HIGH dependency).
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - Static prompt-contract test passes.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching (Option A; 'SPT routes')</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches how to compose a commune/signoff body. Update:

      1. **Option A no-name shape (LOCKED):** producer emits `<project-context>...</project-context>` (NO `name=` attr) when the cwd is inside a tracked project. Remove any `name=` examples if present.

      2. **'SPT routes' phrasing (codex HIGH):** both files use the wording "emit this envelope; SPT routes it to disk." NOT "Psyche writes the file." NOT "the agent persists to the project file." The producer does not name the project; SPT resolves it via Self's perch info.json `cwd` field (25.3-01).

      3. **Routing destination naming:** both files reference the correct Phase 25 schema: `projects/<resolved_project>/<self_id>.md`. Old references to `projects/<self_id>.md` (flat layout — the user's mistaken expectation per the debug doc) must be corrected. Make explicit that `<resolved_project>` is resolved by SPT, not by the agent.

      4. **Cross-reference psyche.md absorption rules:** add a one-line pointer in each file: "Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules (25.3-02)."

      Do NOT introduce new commune subcommands, flags, or file-drop conventions. Strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically thinner; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1 (Task 4); no binary rebuild needed for skill changes.
    </action>
    <verify>
      <automated>Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'project-context' -SimpleMatch; Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'name=' -SimpleMatch | Select-Object -First 5</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<resolved_project>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - No-name shape used throughout (NO `name=` examples).
      - "SPT routes" phrasing present.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Consolidated deploy (Plans 01+03+02) + smoke trace</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe (Plans 01 + 03 changes already committed; rebuild picks up all three plans' edits).

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`. This is the CONSOLIDATED deploy for Plans 01+03+02.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire.
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (PowerShell):
        ```powershell
        Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200 -Wait
        ```
        (run_in_background: true since the agent perch is active.)
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes (literal `<` and `>`, NOT `&lt;`).

      Step 5 — Confirm artifact creation on disk (PowerShell, NO Bash):
      ```powershell
      Get-ChildItem "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify failure — the LLM may need additional prompt tightening, or Plan 04's wrapper-direct route may be required.

      Step 6 — Encoding-contract smoke (codex MEDIUM Plan 03 verification, run here since Plan 03 had no deploy):
      From the same tailed log, confirm the body content delivered to Psyche stdin shows uniform encoding state per Plan 03's chosen Option. NO mixed state.
    </action>
    <verify>
      <automated>Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped (consolidated deploy of Plans 01+03+02).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed; literal tags, no `&lt;`).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
      - Encoding-contract smoke confirms uniform state (NO mixed encoding).
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 03 + 02 together (consolidated):
      - Plan 01 (B2): corrected project-name resolver via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; consolidated deploy.

      Net outcome at this point: outbound `<project-context>` envelopes from the haiku materialize as `projects/<resolved_project>/<self_id>.md` on disk. Inbound envelope absorption is now taught but persistence still depends on the round-trip-via-LLM path (Plan 04 will optionally add wrapper-direct routing).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`).
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando using PowerShell here-string:
         ```powershell
         $body = @'
         <project-context>fresh content marker xyz</project-context>
         '@
         $env:OWL --% deliver todlando "$body"
         # (or use a temp file: $body | Set-Content -NoNewline tmp.txt; $env:OWL deliver todlando --file tmp.txt)
         ```
         Wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears.
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
      5. Read the log tail and confirm the haiku output contains literal `<project-context>` (not `&lt;project-context&gt;`) — Plan 03 verification.
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |
  | Skill files in marketplace clone | Deploy syncs `plugin/spt/skills/*` to the user's plugin cache via DEPLOY.ps1. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=...>` (HYPOTHETICAL — would only exist if Option B were chosen; Option A LOCKS this surface to non-existent) | accept | Option A locked: no-name shape throughout. Future re-introduction of name attr would require a new phase; the static prompt-contract test (`psyche_md_contains_25_3_absorption_section`) asserts no `<project-context name=` substring exists in psyche.md. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via the `(name=<X>)` parenthetical in prompt header | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14). The parenthetical is in the prompt input only; the LLM is taught not to echo it back. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-T2 | Tampering | LLM is tricked into emitting `<project-context name="../../../etc/passwd">` via prompt-injection | mitigate | Option A locked — the LLM is explicitly taught NOT to emit any `name=` attr. SPT-side: even if the LLM disobeyed, `parse_two_slice` ignores attributes (no-name shape parser); routing uses SPT-resolved project name (Plan 01 info.json `cwd` field), never user content. Path traversal blocked twice. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild. Cargo.lock not modified except by deterministic rebuild. DEPLOY.ps1 is in-repo signed script. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds (incorporates Plans 01+03+02 source changes into owl.exe).
  - DEPLOY.ps1 runs successfully; plugin version bumped (consolidated deploy).
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output with LITERAL inner tags (no `&lt;`).
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally.
  - Static prompt-contract test passes.
  - Encoding-contract smoke confirms uniform state at LLM-stdin (Plan 03 verification).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing.
  - commune.md + signoff/SKILL.md aligned with the same shape and phrasing.
  - Binary deployed; in-flight perches handed off.
  - `projects/<resolved_project>/<self_id>.md` artifact materializes on disk for todlando after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract test asserts no `<project-context name=` substring (Option A guard).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Plan 01 + Plan 03 handoff facts (header format + body-state-at-LLM-stdin) used in the psyche.md edits.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt (PowerShell `Get-Content -Tail`) showing both envelopes in haiku output with LITERAL inner tags.
  - Artifact verification: PowerShell `Get-ChildItem` of `projects/` directory contents + first 20 lines of `todlando.md`.
  - Operator-verify response.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test/check that verifies it.
  - Plan 04 handoff: "Inbound envelope absorption rules are in place; round-trip-via-LLM persistence is functional. Plan 04 EITHER adds wrapper-direct routing for resilience (Option 1) OR locks in the LLM round-trip with re-emit hardening (Option 2)."
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - src/owl/poll.rs
    - src/owl/echo_commune.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 2 (was wave 2 originally but
  # logically AFTER 02; cycle-2 puts it BEFORE 02 because 02's psyche.md absorption rules need
  # the body-state-at-LLM-stdin-boundary answer that 03 produces).
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 03 section):
  #   HIGH: "Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope
  #     string. Otherwise user body content can become structural-looking <EVENT> text in the wrong layer."
  #     → Task 1 trace + Task 3 fix: decode order is (1) parse EVENT framing first (attrs decoded as attrs,
  #     body extracted as raw substring), (2) decode body content only AFTER body extraction. Documented in
  #     the new contract block. Tests cover bodies containing literal `</EVENT>`, `<live-context>` nested,
  #     `<br>` literals.
  #   HIGH: "depends_on: [] is risky because this plan depends on the envelope shape chosen in Plan 02."
  #     → RESOLVED by locked decision #1 (Option A — no-name `<project-context>`). Plus reorder: 03 now
  #     precedes 02. `depends_on: [25.3-01]` (needs Plan 01's helper for resolver-aware encoding tests).
  #   MEDIUM: "'Decode at exactly ONE site' is too rigid. Attr decoding already exists, and multiple final
  #     consumers may need body decoding at their own boundary. The better invariant is 'one body decode per
  #     transport traversal.'" → Contract reframed in Task 3 step 3: "One body decode per transport
  #     traversal. Multiple final consumers MAY each apply their own decode at their own boundary, but a
  #     single body MUST NOT traverse two consecutive decode passes within one traversal."
  #   MEDIUM: "The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary
  #     test." → Task 3 Step 5 adds: split/reassembly tests where body content contains literal `</EVENT>`,
  #     `<br>`, and `&amp;` entities AT the chunk boundary.
  #   MEDIUM: "Several commands are Bash-style (tail, heredoc) despite the project's Windows-native
  #     constraint and PowerShell shell." → Task 4 uses PowerShell: `Get-Content -Tail`, PowerShell
  #     here-strings `@'...'@`, `$env:LOCALAPPDATA`. No Bash heredocs.
  #   LOW: "A large contract doc in poll.rs may age poorly if encoding behavior spans modules. A small
  #     encoding.rs module would be cleaner if churn is expected." → Task 3 Step 3 decision: if the contract
  #     doc exceeds ~80 lines OR introduces new helpers beyond `event_body_unescape`, EXTRACT to new
  #     `src/owl/encoding.rs` module. Otherwise inline in `poll.rs`. The decision threshold is documented
  #     so it's deterministic.

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract — attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most ONE body decode per transport traversal."
      - "When a commune EVENT envelope arrives at the Psyche LLM stdin, the body is either CONSISTENTLY encoded (outer EVENT framing escaped and inner `<live-context>` tags ALSO escaped throughout) or CONSISTENTLY decoded (outer framing already consumed by parser; inner `<live-context>` / `<project-context>` tags readable literal). NEVER the mixed state observed at 16:58:53 (outer raw + inner encoded)."
      - "If the producer side is over-encoding inner envelopes (double-encode), that bug is fixed at the source. If a wrapper relay path is adding a second encode, it is removed (relays are pure pass-throughs)."
      - "A new written-down decode contract lives in a single doc surface (`src/owl/poll.rs` top-of-module comment OR new `src/owl/encoding.rs` if the contract exceeds 80 lines / introduces new helpers)."
      - "EVENT-PART chunk-boundary tests cover bodies containing literal `</EVENT>`, `<br>`, and `&amp;` entities at split/reassembly boundaries — no chunk-boundary regression."
      - "User body content containing literal `</EVENT>` at any layer NEVER becomes structural (framing-injection blocked at producer encode; decode happens AFTER framing-parse so user `</EVENT>` cannot reframe upstream)."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites. NEW `event_body_unescape` helper (inverse of `event_body_escape`)."
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding state; EVENT-PART chunk-boundary tests."
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; one encode at producer, one decode AFTER body extraction at consumer"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract holds across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract.

  Per LOCKED DECISIONS:
  - **Plan reorder:** This plan now executes BEFORE Plan 02. Plan 02's psyche.md absorption rules need to know the body-state-at-LLM-stdin-boundary, which is THIS plan's output.
  - **Codex HIGH on decode boundary:** the contract is "attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most one body decode per transport traversal." NOT a global `String::replace` unescape across the entire envelope string.

  This plan:
  1. (Task 1) Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff). Identifies the asymmetric site.
  2. (Task 2) Records the encoding-contract direction decision (consumer sees encoded vs decoded) — checkpoint:decision.
  3. (Task 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the chosen consumer boundary; remove any double-encodes. Writes the contract doc. Adds tests including EVENT-PART chunk-boundary regressions.
  4. (Task 4) Build witness only — `cargo build --release` + `cargo test --release` full suite. NO deploy in this plan. Single integrated deploy lands in Plan 02 (after Plans 01+03 both finish).

  Note on Plan 04 dependency: Plan 04 (Defect A wrapper inbound routing) DEPENDS on this plan's contract — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Task 1's chain trace + Task 2's chosen option supplies Plan 04's input.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites:
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs.

  Decode sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also from-attr decoder).
  - (?) body decode — NO body decode site currently exists. THIS IS THE CONTRACT GAP.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no edits)</files>
    <action>
      Run PowerShell-friendly greps:
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern '&amp;|&lt;|&gt;|&quot;|<br>' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'deliver_body|deliver_body_anonymous|deliver_body_deferred' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch
      ```

      For each emit site (every `format!`/`write!` that constructs an EVENT envelope):
      - Note file:line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site:
      - Note file:line.
      - Note what is decoded (attrs / body / both / neither).
      - Note destination (LLM stdin / Self prompt inject / written to file).

      Trace ONE inbound commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop).
      2. Self listener (poll.rs) detects file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151).
      3. Self listener delivers via owl REST to Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives file_drop EVENT, parses kind+path, reads file contents (raw filesystem read — no encoding), composes `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER.
      5. Wrapper sends composed envelope to Psyche LLM stdin via `resume_session_with_exit`.

      Trace the OUTBOUND echo_commune path:
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH step record: encoded? decoded? pass-through? Identify the asymmetric site.

      Produce a CHAIN TABLE in the SUMMARY:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (find via the grep above — likely in `src/live/wrapper/mod.rs` or sibling). Document its body-encoding behavior exactly.

      State the root-cause hypothesis explicitly and testably in SUMMARY.
    </action>
    <verify>
      <automated>(Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line with specific reason.
      - Root cause hypothesis stated, testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Body-decode boundary — where does the body get decoded to literal form?</decision>
    <context>
      Per locked-decision context (Plan 02 absorption rules need this answer): the Psyche LLM must SEE literal `<live-context>` and `<project-context>` tags (not `&lt;` / `&gt;`). So at SOME consumer-side boundary, the body must be decoded.

      The producer-side encode is necessary because outer `<EVENT>` framing must be unambiguous in spool storage / TCP transport (literal `<EVENT>` inside the body would corrupt framing).

      Per codex HIGH: decode happens AFTER EVENT framing is parsed (NOT a global string unescape). The question is WHERE the body-decode step lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single canonical decode site. Spool/TCP/wrapper-inner-poll all carry encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool/TCP carry encoded; everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay (if relay re-encodes for retransmit, double-encode regression risk).

      Option C — No decode anywhere; teach LLM to handle `&lt;live-context&gt;` literally. Rejected (breaks Plan 02 absorption rules).

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` on inbound envelope, body MUST be decoded first so `parse_two_slice` can find literal `<live-context>`. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether body is already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary (after body extraction)</name>
        <pros>Single canonical decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-encode-once symmetry; Plan 04 can decode in process_file_drop.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication (one extra call to `event_body_unescape`).</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded; harder to audit.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site, write contract doc, add tests including chunk-boundary</name>
    <files>src/owl/poll.rs, src/owl/echo_commune.rs, src/live/wrapper/mod.rs, src/live/commune.rs, tests/native_owl.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - New helper `event_body_unescape` (inverse of `event_body_escape`) added to `src/owl/poll.rs` if it does not already exist. Decode order: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (amp-last invariant — Pitfall 3 inverse, mirrors `parse_event_from_attr` precedent at poll.rs:720).
      - Body decode invoked at the chosen consumer boundary, AFTER EVENT framing has been parsed (NOT a global string-unescape of the entire envelope string).
      - Round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>`.
      - EVENT-PART chunk-boundary regression: a body containing literal `</EVENT>`, `<br>`, and `&amp;` SPLIT across EVENT-PART chunks (Phase 27 chunker) reassembles correctly and decodes cleanly with no mid-chunk corruption.
      - Producer-side double-encodes (if any) removed at the source — NOT compensated downstream.
      - Wrapper relays are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Codex MEDIUM reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site"). Multiple final consumers may each decode at their own boundary.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691):

      ```rust
      /// Inverse of `event_body_escape`. Decode order MUST be `<br>` first, then
      /// tag-shaped entities, with `&amp;`→`&` LAST (Pitfall 3 inverse — same
      /// invariant as `parse_event_from_attr` at poll.rs:720).
      ///
      /// 25.3-03 Defect C: this is the body-side counterpart to the existing
      /// attr-side inverse. Call site invariant: invoke ONLY after EVENT framing
      /// has been parsed (i.e. body has been extracted as a substring); NEVER
      /// apply to the full envelope string globally — that would let user body
      /// content containing literal `</EVENT>` reframe upstream layers.
      ///
      /// Invariant: at most ONE body decode per transport traversal.
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of (choose per Task 1 findings):
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` is encoding a body that was already encoded upstream).
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B) — AFTER body extraction.
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      Document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract. Default location: top-of-module comment in `src/owl/poll.rs`. EXTRACTION TRIGGER: if the contract block exceeds 80 lines OR if Step 2's fix introduces new helpers beyond `event_body_unescape`, EXTRACT to new `src/owl/encoding.rs` module and re-export the existing escape helpers from there (preserve all call sites). Otherwise inline in `poll.rs`.

      Block must cover:
      - Where encoding happens (every producer emit site, file:line).
      - Where decoding happens (the chosen consumer boundary; amp-last invariant; AFTER body extraction).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see (encoded throughout OR encoded up to poll only, per chosen option).
      - Reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site").
      - Forbidden combinations: no double-encode; no global string unescape of the full envelope; no decode BEFORE body extraction.

      Step 4 — Unit tests in `tests/native_owl.rs` (use ONE `cargo test` filter per invocation per cross-plan recommendation):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` (body contains `</EVENT>` — must NOT reframe)
      - `event_body_unescape_inverts_event_body_escape_for_literal_br_in_body` (body contains literal `<br>` text)
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly).

      Step 5 — EVENT-PART chunk-boundary tests (NEW per codex MEDIUM):
      - `event_part_chunk_boundary_with_literal_end_event_in_body` — split a body containing `</EVENT>` mid-chunk; assert reassembly is byte-identical; decode is clean.
      - `event_part_chunk_boundary_with_br_token_at_split` — `<br>` straddles the chunk boundary; assert reassembly preserves the literal `<br>` token.
      - `event_part_chunk_boundary_with_amp_entity_at_split` — `&amp;` straddles the chunk boundary; assert reassembly + amp-last decode produces a single `&`.

      Locate the EVENT-PART chunker (Phase 27) via grep `EVENT-PART|EventPart|split_into_chunks` and use the existing test scaffolds.

      Step 6 — End-to-end test (new file `tests/encoding_chain_end_to_end.rs` OR extend `tests/native_owl.rs` — pick by existing test-file conventions):
      - Fabricate a commune body containing literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>`.
      - Run through full pipeline using test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that hits Psyche stdin.
      - Assert: final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND inner envelope tags are literal `<` / `>`. NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with amp-last invariant.
      - Asymmetric site fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists (in `poll.rs` or extracted to `encoding.rs` per the 80-line threshold).
      - Reframed invariant present: "one body decode per transport traversal."
      - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Full test suite + cargo build witness (NO deploy in this plan)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests). Use ONE filter at a time if scoping; for the full suite no filter.

      Step 2 — `cargo build --release`.

      Step 3 — NO `DEPLOY.ps1` in this plan. Per cross-plan recommendation, deploy is consolidated:
      - Plan 02 will perform the single integrated deploy AFTER Plans 01+03 land (Plan 02's Task 4).
      - Plan 04 will perform the final deploy after the wrapper routing fix lands.

      Step 4 — Smoke-trace placeholder (deferred): record in SUMMARY the EXACT PowerShell command Plan 02 should run to verify the encoding fix post-deploy:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200
      # Look for the next [PSYCHE] resume entry; inspect body for:
      #  - Outer EVENT tag readable (<EVENT type="commune" ...>)
      #  - Inner <live-context>marker-live-xyz</live-context> readable (literal < and >, NOT &lt; / &gt;)
      #  - Inner <project-context>marker-proj-abc</project-context> readable
      #  - No mixed encoding state
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY (codex strength — preserve): "When `process_file_drop` receives a file_drop EVENT, the body is in state X" where X = encoded/decoded per the chosen Option from Task 2. Plan 04 Task 1 reads this directly.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Full test suite passes.
      - `cargo build --release` succeeds.
      - NO deploy in this plan; Plan 02 owns the consolidated deploy.
      - SUMMARY contains the post-deploy smoke command (PowerShell) and Plan 04 handoff note.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. New `event_body_unescape` inverse. Single encode at producer; one body decode per transport traversal at the chosen consumer boundary (Option A or B). EVENT-PART chunk-boundary tests added. Single source-of-truth contract doc block in `src/owl/poll.rs` (or `encoding.rs`). NO deploy yet (Plan 02 deploys after both 01+03).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `src/owl/encoding.rs`). Confirm it describes encode/decode points, the amp-last invariant, AND the reframed "one body decode per transport traversal" invariant — unambiguously.
      2. Inspect the SUMMARY's chain trace table — confirm only ONE encode and AT MOST ONE decode per traversal in the full chain.
      3. Run `cargo test --release` locally (or trust the Task 4 output). Confirm encoding-chain + chunk-boundary tests pass.
      4. Optional: read the asymmetric-site fix diff — confirm the fix is at the SOURCE (not compensated downstream).
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean, the asymmetric site is fixed at the source, and the chunk-boundary tests pass. Or describe remaining asymmetry / failing tests.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`, literal `</EVENT>`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects per chosen Option. |
  | EVENT-PART chunk reassembly | Phase 27 chunker reassembles split bodies; boundary correctness is critical. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. The decode happens AFTER framing has been parsed (codex HIGH) — the body extracted from a parsed envelope cannot reframe upstream layers because they have already consumed the structural tokens. Tested via `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body`. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode of `&amp;lt;` → `<` instead of `&lt;`) | mitigate | Amp-last invariant documented in `event_body_unescape` doc + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-T3 | Tampering | Body content reframes upstream via global string unescape (codex HIGH) | mitigate | The contract explicitly forbids decoding the full envelope string globally. Decode invoked ONLY on the body-extracted substring (post-framing-parse). Contract doc states this; tests enforce. |
  | T-25.3.03-T4 | Tampering | Chunk-boundary corruption at EVENT-PART reassembly | mitigate | New chunk-boundary tests cover `</EVENT>`, `<br>`, `&amp;` straddling splits. Reassembly is byte-identical; decode is post-reassembly. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly. EVENT-PART chunker (Phase 27) caps individual chunk sizes; full-body cap if any exists is documented in `<encoded_body_cap>` (flag for follow-up if no cap exists). |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - NO deploy from this plan.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  - Plan 02 has an unambiguous answer to "what state does the body arrive in at LLM-stdin?" — same source.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root-cause statement.
  - Chosen option (A or B) + rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim — same string that lives in the codebase).
  - Test names + pass evidence (including 3 chunk-boundary tests).
  - Plan 02 handoff note: "Body state at LLM-stdin = <encoded|decoded>" per chosen Option.
  - Plan 04 handoff note: "Body state at process_file_drop = <encoded|decoded>" per chosen Option.
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 4
  depends_on:
    - 25.3-01
    - 25.3-03
    - 25.3-02
  files_modified:
    - src/live/wrapper/mod.rs
    - src/common/tracked.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 4 (was wave 3); 02 precedes 04.
  # Option 1 is LOCKED with stale-overwrite mitigation per codex HIGH. Option 2 (round-trip-via-LLM)
  # is explicitly DISFAVORED.
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 04 section):
  #   HIGH: "Option 1 ignores Plan 02 Option B's possible `name=` attr. If named project-context envelopes
  #     are adopted, routing by receiver last_project_name may write cross-project context to the wrong
  #     file." → RESOLVED by Plan 02 locked Option A (no-name shape). Routing always uses receiver-side
  #     resolver (Self's perch info.json `cwd` via Plan 01's helper). No name attr to consider.
  #   HIGH (locked decision #2): "Routing must use receiver-side resolver (info.json cwd per locked
  #     decision #2), NOT name= attr." → Task 2 routes via Plan 01's `resolve_self_project_name_via_info_cwd`
  #     helper. Same resolver as build_current_context_blocks — no third-site resolver.
  #   HIGH: "Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body.
  #     The plan acknowledges this but does not mitigate it." → Task 2 Step 2 adds STALE-OVERWRITE
  #     MITIGATION via monotonic content-merge: route_two_slice gets a new `inbound_baseline: Option<&str>`
  #     parameter (or sibling helper) that compares the new body against the existing on-disk content;
  #     if new body is a STRICT SUBSET (LLM emitted stale content that drops markers introduced by direct
  #     route), the write is REJECTED and a warning is logged. See task action for full design.
  #   HIGH: "Option 2 (round-trip-via-LLM) was the failure mode — DISFAVOR unless paired with enforcement.
  #     Plan should LOCK Option 1." → Option 1 is LOCKED in this plan. Task 1 checkpoint:decision retained
  #     but with a default + strong recommendation toward Option 1. Option 2 explicitly disfavored.
  #   MEDIUM: "Route failures must surface clear warning/status (not silently nonfatal)." → Task 2 Step 3:
  #     route failures emit `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)`
  #     as a structured WARN-level log; also bump a counter in info.json `route_failure_count` (optional
  #     telemetry — defer if scope too tight). Non-fatal but VISIBLE.
  #   MEDIUM: "Extract small testable helper (route_inbound_commune_body) instead of testing through full
  #     wrapper process flow. Call out dependency injection." → Task 2 Step 1 extracts
  #     `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-
  #     testable seam. process_file_drop invokes the seam; tests target the seam directly without mocking
  #     resume_session_with_exit.
  #   LOW: "$OWL deliver heredoc not PowerShell-compatible." → Task 4 uses PowerShell here-string `@'...'@`
  #     piped into a temp file or PowerShell `--%` literal-args mode. Bash heredocs eliminated throughout.

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<resolved_project>/<self_id>.md` on disk via wrapper-side `route_two_slice` call BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver (Self's perch info.json `cwd` → `project_name_from_cwd_path`). NOT a `name=` attr (Option A locked, no such attr exists)."
      - "STALE-OVERWRITE MITIGATION: the next echo_commune fire's LLM output CANNOT erase the direct-routed content. Monotonic merge guard: if the LLM's emitted `<project-context>` body is a STRICT SUBSET of the on-disk content (key markers missing), the merge writer warns and rejects the write OR keeps the larger version per defined rule (see Task 2)."
      - "The wrapper's `process_file_drop` is the gatekeeper for inbound routing; no other inbound envelope route exists."
      - "Route failures emit a STRUCTURED WARN log (`[FILE-DROP] route_two_slice failed for {self_id}: ...`); non-fatal but VISIBLE (codex MEDIUM)."
      - "End-to-end test demonstrates: inbound payload with marker text → marker appears in `projects/<name>/<self_id>.md` within SECONDS (Option 1 timing — synchronous, before LLM handoff)."
      - "Implementation is unit-testable via the extracted `route_inbound_commune_body(self_id, body, source)` helper (codex MEDIUM); tests do NOT need to mock `resume_session_with_exit`."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop calls new `route_inbound_commune_body` helper before LLM handoff (Option 1 LOCKED). Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/common/tracked.rs"
        provides: "New `route_two_slice_with_baseline` variant (or new `route_two_slice_monotonic` sibling) that implements stale-overwrite mitigation. Or a small wrapper `merge_project_body_monotonic`."
        contains: "monotonic"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + stale-overwrite mitigation tests."
        contains: "route_inbound_commune_body"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/live/wrapper/mod.rs::route_inbound_commune_body (NEW seam)"
        via: "synchronous direct-route call BEFORE LLM handoff"
        pattern: "route_inbound_commune_body"
      - from: "src/live/wrapper/mod.rs::route_inbound_commune_body"
        to: "src/owl/echo_commune.rs::resolve_self_project_name_via_info_cwd (Plan 01)"
        via: "receiver-side project resolver — same source as build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd|project_name_from_cwd_path"
      - from: "src/common/tracked.rs::route_two_slice"
        to: "stale-overwrite mitigation (monotonic merge guard)"
        via: "comparison of LLM-emit body against current on-disk content; subset → reject + warn"
        pattern: "monotonic|merge_project_body"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with codex HIGH mitigations locked in. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — never calls `route_two_slice` on the inbound envelope. Inbound `<project-context>` envelopes are silently lost when the LLM round-trip drops them (the failure mode that triggered Phase 25.3).

  Per LOCKED DECISIONS + codex HIGH:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies via `route_two_slice` BEFORE LLM handoff. Option 2 (round-trip-via-LLM) was the original failure mode and is explicitly DISFAVORED — codex HIGH. The checkpoint:decision in Task 1 records the choice but defaults to Option 1.

  - **Receiver-side resolver (locked decision #2).** Routing uses Plan 01's `resolve_self_project_name_via_info_cwd` (info.json `cwd` field) for project resolution — same source as `build_current_context_blocks`. NO `name=` attr (Option A locked in Plan 02).

  - **Stale-overwrite mitigation (codex HIGH).** After direct-route persists the inbound body, the next echo_commune fire's LLM output MUST NOT erase the routed content. Solution: monotonic merge guard — `route_two_slice` (or a sibling) compares new content against existing on-disk content; if the new body is a STRICT SUBSET (drops markers), the write is rejected and a warning is logged. The exact merge policy is defined in Task 2.

  - **Testable seam (codex MEDIUM).** Extract `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-testable helper. Tests target the seam directly — do NOT mock `resume_session_with_exit` or run a full wrapper process flow.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in <STATE> form per Plan 03 SUMMARY. Task 2 reads that handoff note.

  Output:
  - User decision recorded at Task 1 checkpoint (default Option 1 with strong recommendation).
  - `route_inbound_commune_body` helper extracted as testable seam.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - Stale-overwrite mitigation: monotonic merge guard in `route_two_slice` (or sibling).
  - End-to-end test demonstrating synchronous inbound persistence.
  - Binary rebuild + DEPLOY (final patch bump for Phase 25.3).
  - Operator-verify checkpoint.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for Option 1 implementation. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318).
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315).
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).

  From src/owl/echo_commune.rs (Plan 01 outcome):
  - `fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01. Reads Self's perch info.json `cwd` field and resolves via `project_name_from_cwd_path`.
  - This plan REUSES that helper at the wrapper site (the third resolver consumer — codex MEDIUM noted duplication concern is now a single shared helper).

  From Plan 03 outcome (read 25.3-03-SUMMARY.md at execute time):
  - Body state at wrapper file_drop boundary = <encoded|decoded>. If Plan 03 chose Option A (decode at LLM-stdin), the wrapper file_drop site sees DECODED body (because filesystem read yields raw user prose with literal tags). If Plan 03 chose Option B (decode at poll), same outcome: decoded body. Either way: at process_file_drop, body has LITERAL `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - The LLM is taught "SPT routes" — meaning this plan IS that SPT routing.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 03 SUMMARY to confirm body-state at process_file_drop boundary</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract the Plan-04-handoff line: "Body state at process_file_drop = <encoded|decoded>."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      If the body is ENCODED at process_file_drop, Task 2 inserts a `crate::owl::poll::event_body_unescape` call before invoking `route_inbound_commune_body`.

      If the body is DECODED (expected case — filesystem read of user-written commune file), no decode pass needed.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read.
      - Body state at process_file_drop documented in this plan's SUMMARY.
      - Task 2 has the decode-or-not answer.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper inbound routing strategy — Option 1 (LOCKED default) vs Option 2 (DISFAVORED)</decision>
    <context>
      Per locked decisions + codex HIGH: Option 1 (direct wrapper route before LLM handoff) is LOCKED as the default and strongly recommended. Option 2 (round-trip-via-LLM with re-emit hardening) is DISFAVORED — it relies on prompt compliance, which is the failure mode that triggered this phase. This checkpoint exists for record-keeping; the strong recommendation is Option 1.

      Option 1 (LOCKED default) — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call the new `route_inbound_commune_body(self_id, body, "commune-inbound")` seam.
      - Synchronous persistence; inbound envelope hits disk immediately.
      - Survives a Psyche LLM that drops the envelope.
      - STALE-OVERWRITE MITIGATION (codex HIGH): the next echo_commune fire's LLM output cannot erase the routed content (monotonic merge guard).
      - Failure mode of original Defect A is eliminated.

      Option 2 (DISFAVORED) — Do not modify process_file_drop; rely on Plan 02's absorption + re-emit teaching only.
      - Smaller code surface but no defense against LLM dropping the envelope.
      - Only acceptable if the user actively chooses this path over codex's HIGH recommendation.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_inbound_commune_body before LLM handoff (LOCKED DEFAULT)</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune. Receiver-side resolver via Plan 01 (no third resolver site — same shared helper). Stale-overwrite mitigation via monotonic merge guard.</pros>
        <cons>Marginally more code than Option 2; requires the monotonic merge guard.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM only (DISFAVORED per codex HIGH)</name>
        <pros>Smaller code surface.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss. Codex HIGH explicitly disfavors. Do not select unless user has a specific reason.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 (default; press enter to accept) or option-2 (must explicitly type with rationale).</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Extract route_inbound_commune_body seam + wire process_file_drop + add stale-overwrite mitigation</name>
    <files>src/live/wrapper/mod.rs, src/common/tracked.rs</files>
    <behavior>
      - SKIP this task if Option 2 chosen at Task 1; proceed to Task 3.
      - `route_inbound_commune_body(self_id, body, source)` (NEW helper in `src/live/wrapper/mod.rs` or a sibling module) is unit-testable WITHOUT mocking `resume_session_with_exit` or running a full wrapper process.
      - When invoked with a body containing `<project-context>marker-abc</project-context>`, `projects/<resolved_project>/<self_id>.md` contains `marker-abc` on disk after return (assuming Plan 01's resolver returns a valid project for self_id).
      - When invoked with a body containing NO envelope tags, parse_two_slice puts the entire body in the live slot (existing fallback) → `live_context.md` is updated; no project write.
      - When invoked and resolver returns None (no project resolved for self_id), the project slice is dropped silently per existing route_two_slice D-09; live slice still routes.
      - **Stale-overwrite mitigation:** when a SUBSEQUENT echo_commune fire's LLM output emits a `<project-context>` body that is a STRICT SUBSET of the current on-disk content (a defined "subset" rule — see action), the merge writer REJECTS the write and emits a structured WARN log. This protects the direct-routed inbound marker from being erased by a stale LLM emit.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Route failures are structured WARN logs and non-fatal — LLM handoff continues regardless.
      - "signoff" arm is unchanged.
    </behavior>
    <action>
      Step 1 — Extract the testable seam in `src/live/wrapper/mod.rs`. Add a new function near `process_file_drop`:

      ```rust
      /// 25.3-04 Defect A — extracted testable seam for inbound commune routing.
      ///
      /// Parses `body` via `parse_two_slice`, resolves Self's project via the
      /// receiver-side resolver from Plan 01 (info.json `cwd` field), and routes
      /// each slice to disk. NO LLM handoff in this function — that is the caller's
      /// responsibility.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM):
      /// callers pass `self_id` + raw body; the function consumes Plan 01's resolver
      /// and Plan 25's route_two_slice (with monotonic merge guard for project slice).
      ///
      /// Returns RouteOutcome on success (records which slices were routed);
      /// Err(String) on hard failures (resolver unreachable, IO error).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,
      ) -> Result<RouteOutcome, String> {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());

          // Plan 03 contract reference: body is in DECODED form at this site
          // (filesystem read of user-written commune file).
          // If Plan 03 SUMMARY's handoff note says ENCODED, decode here:
          // let body = crate::owl::poll::event_body_unescape(body);
          // (Task 0 already confirmed the correct branch.)

          // route_two_slice_monotonic is the new sibling that applies the
          // stale-overwrite guard for the project slice. For inbound source =
          // "commune-inbound", we want UNCONDITIONAL write of the inbound delta
          // (this IS the source of truth at this moment), so we use the standard
          // route_two_slice. The monotonic guard fires on the SUBSEQUENT
          // echo_commune emit only — see Step 2.
          crate::common::tracked::route_two_slice(self_id, body, source, &short)
              .map(|_| RouteOutcome { live_routed: true, project_routed: true })
              .map_err(|e| e.to_string())
      }

      pub(crate) struct RouteOutcome {
          pub live_routed: bool,
          pub project_routed: bool,
      }
      ```

      Step 2 — Add the STALE-OVERWRITE MITIGATION in `src/common/tracked.rs`. The guard fires on the OUTBOUND echo_commune emit path (NOT on inbound — inbound is the source of truth). Implementation: add a new function `route_two_slice_monotonic` OR extend `route_two_slice` with an internal subset-check for the project slice.

      Recommended approach: add `fn route_two_slice_monotonic(self_id, body, source, short)` as a SIBLING that wraps `route_two_slice` with this additional guard ONLY for the project slice:

      ```rust
      /// 25.3-04 stale-overwrite mitigation. Identical to `route_two_slice` except:
      /// before writing the project slice, READ the current on-disk project file.
      /// If the new body is a STRICT SUBSET of the existing content per the
      /// `is_strict_subset` predicate (see below), REJECT the write and emit a
      /// structured WARN log: `[ROUTE-GUARD] rejected stale project body for {self_id}
      /// (new body is strict subset of on-disk; preserving existing)`.
      ///
      /// Subset rule (defined per 25.3-04 Task 2 codex HIGH mitigation):
      ///   `is_strict_subset(new, existing)` = true iff EVERY non-blank line of
      ///   `existing` is present in `new` is FALSE for at least one line AND every
      ///   non-blank line of `new` appears in `existing`. Translation: `new` is
      ///   missing markers that `existing` has, and `new` does not introduce any
      ///   new content. This is the "LLM dropped content" signature.
      ///
      /// Live slice is ALWAYS unconditionally written (no monotonic guard on
      /// live_context.md — it is intentionally ephemeral).
      pub fn route_two_slice_monotonic(
          self_id: &str,
          body: &str,
          source: &str,
          short: &str,
      ) -> Result<(), String> { /* impl */ }
      ```

      Wire the OUTBOUND echo_commune fire (`src/owl/echo_commune.rs::run_echo_commune` at line 854) to use `route_two_slice_monotonic` instead of `route_two_slice`. INBOUND wrapper-direct route (Task 2 Step 1) keeps the unconditional `route_two_slice`.

      Step 3 — Update `process_file_drop` in `src/live/wrapper/mod.rs`. Inside the "commune" arm, BEFORE composing the EVENT envelope (currently at line 1278):

      ```rust
      // 25.3-04 Option 1 (LOCKED): wrapper-direct route inbound commune body
      // BEFORE LLM handoff. The LLM still receives the envelope below; the route
      // is a synchronous SIDE EFFECT that hardens against LLM drops. Stale-
      // overwrite is mitigated by the monotonic guard on the OUTBOUND
      // echo_commune emit (route_two_slice_monotonic).
      if kind == "commune" {
          match route_inbound_commune_body(&self.self_id, &body, "commune-inbound") {
              Ok(outcome) => self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound; live={}, project={})",
                  self.self_id, outcome.live_routed, outcome.project_routed
              )),
              Err(e) => self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              )),
          }
      }
      ```

      Codex MEDIUM (visible failures): the WARN log is structured with a stable prefix `[FILE-DROP]` so operators can `Select-String -Pattern '\[FILE-DROP\] route_two_slice failed'` to find failures.

      Step 4 — Unit tests targeting the seam (NO wrapper mocking — codex MEDIUM):
      - `route_inbound_commune_body_writes_project_slice_with_marker` — set up fake tracked tree + info.json with cwd → temp .git repo; pass body with `<project-context>marker-abc</project-context>`; assert `projects/<name>/<self_id>.md` contains "marker-abc". `#[serial_test::serial]` (env-mutating).
      - `route_inbound_commune_body_writes_live_slice_only_when_no_project_envelope` — body with only `<live-context>...</live-context>`; assert live_context.md updated; no project file. `#[serial]`.
      - `route_inbound_commune_body_no_envelope_tags_fallback` — body with no tags; parse_two_slice fallback routes to live slot. `#[serial]`.
      - `route_inbound_commune_body_returns_err_when_resolver_fails` — info.json missing AND process cwd resolves to psyche_dir; assert resolver fails gracefully (the existing route_two_slice D-09 silent-drop applies). `#[serial]`.

      Stale-overwrite mitigation tests (target `route_two_slice_monotonic` directly):
      - `route_two_slice_monotonic_rejects_strict_subset_project_body` — write `marker-a\nmarker-b\nmarker-c` to project file; then call with body `<project-context>marker-a\nmarker-b</project-context>`; assert file content UNCHANGED + WARN log emitted. `#[serial]`.
      - `route_two_slice_monotonic_accepts_strict_superset` — same setup; call with body adding `marker-d`; assert file content updated to include marker-d. `#[serial]`.
      - `route_two_slice_monotonic_accepts_overlapping_with_new_content` — new body shares some markers but adds a new one; assert write goes through. `#[serial]`.
      - `route_two_slice_monotonic_always_writes_live_slice` — verify the monotonic guard applies ONLY to project slice; live_context.md is unconditionally updated. `#[serial]`.

      End-to-end ordering test:
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` — verify route_inbound_commune_body is called BEFORE resume_session_with_exit in the "commune" arm. This can be a structural test (read process_file_drop source order) OR a small mock-injection test if existing test scaffolds permit. If structural, document as such.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_monotonic 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, unit-testable without LLM mocking.
      - `route_two_slice_monotonic` exists in `src/common/tracked.rs` with `is_strict_subset` predicate.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
      - Outbound echo_commune fire uses `route_two_slice_monotonic` (stale-overwrite mitigation).
      - Inbound wrapper-direct uses standard `route_two_slice` (unconditional, source of truth at that moment).
      - Route failures are structured WARN logs (operator-visible).
      - 4 seam tests + 4 monotonic-guard tests + 1 ordering test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path — DISFAVORED): Harden psyche.md re-emit guarantee only</name>
    <files>psyche.md</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1 (the LOCKED default). Proceed to Task 4.

      This task only executes if the user explicitly overrode the codex HIGH recommendation and selected Option 2. In that case:

      Add to psyche.md `<absorption>` section (built in Plan 02), a new rule:

      "**Re-emit guarantee (Option 2 — 25.3-04):** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Add a Rust-side regression detector in `src/owl/echo_commune.rs::run_echo_commune` (after haiku stdout capture, before route_two_slice at line 854):
      - If `prompt.contains("CURRENT_PROJECT_CONTEXT")` AND NOT `output.contains("<project-context>")`, log:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (25.3-04 Option 2 re-emit guarantee violated)", self_id);
        ```

      This is observability only — Option 2 explicitly does NOT enforce.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'Re-emit guarantee' -SimpleMatch | Measure-Object).Count; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - (Option 2 only) psyche.md has "Re-emit guarantee" rule.
      - (Option 2 only) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (Option 1 path)</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — Final deploy for Phase 25.3:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      STOP before `/reload-plugins` (per CLAUDE.md). User runs it.

      Step 4 — After user reload, send a deliberate inbound commune to todlando using PowerShell (NOT Bash heredoc — codex LOW):

      Approach A — temp file:
      ```powershell
      $body = @'
      <project-context>marker-25.3-04-Z timestamp-test</project-context>
      '@
      $tmp = New-TemporaryFile
      Set-Content -NoNewline -Path $tmp -Value $body
      & $env:OWL deliver todlando --file $tmp
      Remove-Item $tmp
      ```

      Approach B — PowerShell literal-args mode `--%`:
      ```powershell
      $env:OWL --% deliver todlando "<project-context>marker-25.3-04-Z timestamp-test</project-context>"
      ```

      Use whichever approach the `$OWL deliver` CLI supports — inspect `--help` first.

      Step 5 — Verification (Option 1 timing):
      Within SECONDS (before the next echo_commune fire — Option 1 is synchronous), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok for todlando \(commune-inbound'
      ```

      Step 6 — Stale-overwrite mitigation smoke: wait for the next echo_commune fire and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that is a STRICT SUBSET (missing the marker), confirm the `[ROUTE-GUARD] rejected stale project body` log line appears in todlando.log.
      - If the haiku emitted a SUPERSET (preserving the marker + adding more), confirm the file content is the new superset.

      Step 7 — Confirm the on-disk file content's marker text round-tripped intact (no encoding artifacts — Plan 03's chain verification).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for Phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` within SECONDS of inbound commune.
      - `[FILE-DROP] route_two_slice ok` log line confirmed for the inbound.
      - Stale-overwrite mitigation verified: either marker preserved across next echo_commune fire OR `[ROUTE-GUARD] rejected stale project body` log line confirmed.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 5: Phase-level invariant test (cross-plan; codex cross-plan recommendation #6)</name>
    <files>tests/native_owl.rs</files>
    <action>
      Add one phase-level invariant test per codex cross-plan recommendation #6:

      `phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`

      Scenario:
      - Set up a temp SPT_HOME with a fake perch info.json for `test-agent` carrying `cwd = <path to a temp .git repo named "test_project">`.
      - Invoke `route_inbound_commune_body("test-agent", body, "commune-inbound")` with body:
        ```
        <live-context>live-marker-aaa</live-context>
        <project-context>project-marker-bbb</project-context>
        ```
      - Assert ALL THREE invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO HTML-encoded tags in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;`, no `&amp;`). (Defect C invariant.)
        (c) Marker text in expected file — `projects/test_project/test-agent.md` contains `project-marker-bbb`; `agents/test-agent/live_context.md` contains `live-marker-aaa`. (Defects D + A invariant.)
      - `#[serial_test::serial]` (env-mutating).

      This is the single integration test that validates the entire phase 25.3 fix as a unit. If this test passes, the four defects are jointly resolved.
    </action>
    <verify>
      <automated>cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly 2>&1 | Select-Object -Last 15</automated>
    </verify>
    <done>
      - Phase-level invariant test exists and passes.
      - Test asserts no `"tracked"` artifact, no HTML-encoded tags, correct marker placement.
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; stale-overwrite mitigation via `route_two_slice_monotonic`; structured failure logs.
      - Cross-plan: phase-level invariant test (Task 5) jointly validates all four defects.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk, with stale-overwrite protection against subsequent LLM drops.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to todlando via PowerShell:
         ```powershell
         $body = @'
         <project-context>final-verify-marker-25.3</project-context>
         '@
         # via temp file or $env:OWL --% deliver — see Task 4 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok'
         ```
      4. Send a SECOND `<project-context>` with a different marker and confirm both markers coexist OR the second's content correctly supersedes (depending on whether the second is a superset or causes the monotonic guard to fire).
      5. Confirm `live_context.md` continues to update normally (no regression on live slice).
      6. Confirm no `projects/tracked/` directory exists (Defect B2 invariant).
      7. Run phase-level invariant test: `cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`.
    </how-to-verify>
    <resume-signal>Type "approved" if all four defects are jointly resolved per the verification steps, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md`. |
  | Monotonic merge guard | Reads existing on-disk content; compares against new body; rejects on strict-subset. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | Option A no-name shape (Plan 02 lock) — there is NO attribute in the envelope that can carry a path. Destination path is resolved by SPT via Plan 01's info.json `cwd` resolver, never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice` joins under `tracked_root()/projects/<resolved_name>/` with no path-traversal escape. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with LLM round-trip) | mitigate | STALE-OVERWRITE MITIGATION (codex HIGH): `route_two_slice_monotonic` applies the `is_strict_subset` guard on the OUTBOUND echo_commune emit path. If the LLM emits a body that drops markers present on disk, the write is REJECTED and a WARN log surfaces. Inbound direct-route remains unconditional (it IS the new source of truth). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to defeat the subset guard (e.g. paraphrases markers so they look new) | accept | The subset guard is a heuristic, not a cryptographic check. An attacker controlling inbound content can already inject arbitrary prose into the project file — the route_two_slice surface trusts inbound by design. The guard protects against ACCIDENTAL LLM stale emit, not adversarial input. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes. Monotonic guard adds one extra disk read per write; negligible. |
  | T-25.3.04-R1 | Repudiation | Route failure goes unnoticed | mitigate | Structured WARN logs with stable prefix `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Codex MEDIUM resolution. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision (default Option 1).
  - Task 2 implements Option 1 with stale-overwrite mitigation; Task 3 skipped (Option 2 disfavored).
  - Task 4 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 5 phase-level invariant test asserts joint resolution of all four defects.
  - Task 6 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded (Option 1 default).
  - `route_inbound_commune_body` seam exists; unit-testable without LLM mocking.
  - `route_two_slice_monotonic` exists with `is_strict_subset` guard.
  - process_file_drop "commune" arm routes BEFORE LLM handoff.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS (Option 1 synchronous timing).
  - Stale-overwrite mitigation verified: next echo_commune fire CANNOT erase direct-routed inbound content.
  - Phase-level invariant test passes (joint resolution of all 4 defects).
  - All PowerShell — no Bash heredocs (codex LOW).
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 LOCKED default OR 2 with explicit user override rationale).
  - Plan 03 body-state-at-process_file_drop confirmation (from Task 0).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call).
  - Diff summary for `src/common/tracked.rs` (`route_two_slice_monotonic` + `is_strict_subset`).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content).
  - Stale-overwrite mitigation smoke verification.
  - Phase-level invariant test pass evidence.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task → verification.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 03, 02, 04 — in execution order) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist synchronously with stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Compare cycle-2 plans against your cycle-1 concerns. For each prior HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then provide a fresh per-plan review with severities (HIGH/MEDIUM/LOW). Mark severity bullets exactly as: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **Overall risk after revision**: LOW/MEDIUM/HIGH + one-line justification. Mention if convergence reached (no remaining HIGHs).

preview:
  # Cross-AI Plan Review Request (Cycle 2 — Replan)

  These plans were revised after a Cycle 1 review surfaced 9 HIGH concerns. Verify whether HIGHs are resolved and whether new issues were introduced.

  ## Project Context
  # Owl Skill (SPT — Spacetime / Sentience Pocket Transacter)

  ## What This Is

  A zero-dependency inter-agent messaging and live-agent lifecycle system for Claude Code, shipped as the `spt` plugin via the `cplugs` marketplace. Native Rust binary (`owl.exe`) uses TCP-primary delivery with SQLite spool fallback. Live agents run a detached Psyche wrapper (periodic `claude -p --resume` sessions) that absorbs `COMMUNE` updates from Self and reacts to scheduled pulses, file-drops, and SessionStart boundaries. Listener now streams `<EVENT>` envelopes via Claude Code's Monitor tool. Used by multiple people — Windows-native portability with no runtime dependencies is a hard constraint.

  ## Core Value

  Reliable, zero-dependency agent-to-agent messaging that just works on any machine, with a live-agent layer that survives `/clear`, `/compact`, binary handoffs, and orphan boundaries without losing context.

  ## Current State

  **Shipped: v1.7.1 Seamlessification II** (2026-05-18; deployed v1.10.13)

  Four phases (31-34) closed out the v1.7 polish arc — listing/picker correctness, skill hint surface, fresh-start commune flow, version-change notification:
  - **Phase 31** — `$LIVE pick-spec` `kind:"all-live"` variant (additive, D5 schema freeze preserved)
  - **Phase 32** — `--all` / `--offline` / `--here` flags default-online; 17/17 SKILL.md `argument-hint` frontmatter coverage
  - **Phase 33** — First-commune AskUserQuestion + `/spt:live --auto` + SessionStart `<spt-live-auto-pick>` emission + 8 casual-language triggers (3 explicit non-triggers)
  - **Phase 34** — `$SPT_HOME/last-seen-version.json` sentinel + Stop-hook silent owl-message transport + CHANGELOG.md (40 H2 entries) + DEPLOY.ps1 curation gate

  11 plans, 94+ commits, 3-day execution window. Hotfix bundle folded in (260517-6om transport pivot, 260517-n4b UAT defect cleanup, 260517-wpf CHANGELOG-ordering closure). 39/39 v1.7.1 requirements verified end-to-end (6/6 cross-phase wiring + 6/6 E2E flows). Audit: `.planning/v1.7.1-MILESTONE-AUDIT.md` — PASS.

  <details>
  <summary>Previously shipped: v1.7 Seamlessification (2026-05-16; deployed v1.10.10)</summary>

  Five phases (26-30) focused on friction removal across the live-agent lifecycle:
  - **Phase 26** — `/spt:live` auto-pick + AskUserQuestion fork + free-text resolve
  - **Phase 27** — Listener poll migrated from Bash to Monitor tool (streaming `<EVENT>` envelopes)
  - **Phase 28** — SessionStart injects `<psyche-context>` on live perches (`/clear` & `/compact`)
  - **Phase 29** — Auto-fire echo-commune at boundaries + typed `<EVENT type="echo_commune">` envelope cutover
  - **Phase 30** — Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) replaces stdin piping

  26 plans, 211 commits, 7-day cycle. Off-roadmap sub-iterations 18.x continued through v1.10.x defense-in-depth deploys (stale-signoff drain, init_signoff envelope-shape predicate, FileDropOutcome::BreakLoop).

  </details>

  ## Next Milestone Goals

  **v1.8 Psyche Restructure** — Phases 23/24/25:
  - Phase 23 — commune/signoff project-root + HEAD SHA stamping (per-repo context isolation)
  - Phase 24 — tracked-dir per-agent layout + sessions audit log
  - Phase 25 — psyche perch nesting + general/project context split

  Backlog candidates surfaced during v1.7.1:
  - Deferred PICK-03 (`online: bool` field on `kind:"pick"`)
  - `download_payload_for_injection` SessionStart destructive default — `--read-only` flag consideration
  - Shared-sentinel awareness (Phase 34 + quick-260517-rhw primer co-consume `$SPT_HOME/last-seen-version.json`)
  - 21 unrelated parallel-test failures in Phase 18/28/30/32 surfaces (pass in isolation; cleanup quick task candidate)

  ## Requirements

  ### Validated

  - ✓ Harden owl.sh against race conditions and edge cases — v1.0
  - ✓ Unified output formatting: compact symbols, ANSI color codes, no emoji — v1.0
  - ✓ Auto-setup env vars and session cleanup hook — v1.0
  - ✓ Live Agent start + Psyche subagent + periodic pulse + COMMUNE format — v1.0
  - ✓ Native Rust binary with P2P TCP messaging + SQLite offline spool — v1.1
  - ✓ Golden test suite + cross-platform CI (4 targets) — v1.1
  - ✓ Timed pulses + Spine supervisor + single-binary drop-in — v1.1
  - ✓ Plugin migration to `/spt` namespace — v1.5
  - ✓ Spacetime filesystem (`%LOCALAPPDATA%\spt` / `~/.spt`) with `SPT_HOME` override — v1.5
  - ✓ Persistent offline perches + indefinite queueing + reconnection — v1.5
  - ✓ Memformat-driven Psyche brainstorming + INSIGHT messages — v1.5
  - ✓ PerchState enum + owl doctor + session detection + optional agent_id — v1.5
  - ✓ Hand-rolled MCP server (JSON-RPC over stdio) — v1.6
  - ✓ Spool unification (peek_all + mark_delivered) + Result variant refactor — v1.6
  - ✓ 13 MCP tools + 5 MCP resources + check-on-request notifications — v1.6
  - ✓ Seamless binary handoff for long-running spt processes — v1.6 (Phase 18.4/18.5 sub-iterations)
  - ✓ Echo-commune fresh-session jsonl excerpt + 15-min cadence gate — v1.6 (Phase 18.8 sub-iteration)
  - ✓ `/spt:live` auto-pick + AskUserQuestion fork + `$LIVE pick-spec`/`$LIVE fork` — v1.7
  - ✓ Listener poll: Monitor tool primary + `<EVENT>` envelopes + Bash `--once` fallback — v1.7
  - ✓ SessionStart `<psyche-context>` injection on live perches — v1.7
  - ✓ Auto-fire echo-commune on `/clear`+`/compact`+orphan; typed envelope cutover — v1.7
  - ✓ Commune/signoff file-drop protocol (`.claude/{id}-{commune,signoff}.md`) — v1.7

  ### Active

  - Phase 23 — Commune/signoff payload includes `project_root` + `HEAD` SHA (v1.8 candidate)
  - Phase 24 — `psyches/tracked/<self_id>/` per-agent layout + sessions audit log (v1.8)

  ## Phase 25.3 — Roadmap Section
  ### Phase 25.3: project-context envelope persistence + encoding defects (INSERTED)

  **Goal:** Resolve three defects surfaced during `todlando-project-context-not-persisted` debug session (2026-05-22). Inbound `<project-context>` envelopes are reaching Psyche but never persisted to disk at the Phase 25 schema location `projects/<project_name>/<self_id>.md`. Root causes: (B2) asymmetric project-name resolver between context-block builder and EVENT stamper within same echo_commune fire, (A) wrapper relies on Psyche LLM round-trip to merge inbound envelopes with no direct route_two_slice on ingestion, (C) selective HTML-encoding leak where inner envelope tokens reach Psyche as `&lt;...&gt;` while outer `<EVENT>` and `<br>` tokens survive raw. Diagnostic source: `.planning/debug/todlando-project-context-not-persisted.md`.
  **Requirements**: none (urgent insert, no req mapping)
  **Depends on:** Phase 25 (nested layout + two-slice context blocks); Phase 24.1 (`info.json` `last_project_name`, D-09); Phase 25.1 (two-slice envelope teaching + sentinel emission)
  **Status:** Plans pending
  **Plans:** TBD

  ### Defect roster (each requires blast-radius sanity check before plan)

  **B2. Asymmetric project resolver in `build_current_context_blocks`**
  - Root: `src/owl/echo_commune.rs:411` calls `derive_current_repo_names()` which reads `std::env::current_dir()`. Haiku spawns with cwd = `psyche_dir()` = `%LOCALAPPDATA%\spt\psyches\tracked` per D-05. That dir has no `.git` parent → D-06 fallback returns `["tracked"]` — bogus project name leaks into the two-slice context payload.
  - Same fire pass at `src/owl/echo_commune.rs:869` correctly resolves via `resolve_self_project_stamp(self_id)` which reads Self's perch `info.json` `last_project_name` (Phase 24.1 D-09). Outbound EVENT envelope gets the right project; inner context-block contents do not.
  - Primary fix: `build_current_context_blocks` must prefer Self info.json `last_project_name` before falling back to cwd-derived names, and explicitly reject the `"tracked"` D-06 false positive when the haiku is the caller.
  - **Blast radius:** every consumer of `build_current_context_blocks`. Audit: echo_commune fire path (this site), signoff prompt-build path (`src/live/signoff.rs` callers of the same helper), pulse trigger context assembly, init trigger. Also `derive_current_repo_names()` itself — confirm no other caller relies on the `"tracked"` fallback as intended behavior. Also Phase 24.1 D-09 stamping vs Phase 25 schema interaction — `last_project_name` must match the `projects/<name>/` subdir convention exactly (no normalization drift between stamper and consumer).

  **A. Wrapper does not route inbound commune envelopes through `route_two_slice`**
  - Root: `src/live/wrapper/mod.rs::process_file_drop` → `resume_session_with_exit` feeds inbound commune bodies to the Psyche LLM verbatim and discards the LLM reply. Architecturally relies on the Psyche to merge in-memory and re-emit on the NEXT echo_commune fire. When Defect B2 corrupts the next fire's outbound envelope, the inbound `<project-context>` is silently lost.
  - Primary decision: either (a) wrapper extracts `<project-context>` from inbound payloads and calls `route_two_slice` directly before handing to LLM, OR (b) confirm round-trip-via-LLM is intended and harden psyche.md to always re-emit envelopes on the next fire.
  - **Blast radius:** every inbound envelope type the wrapper currently forwards LLM-only (commune, latent-signoff, project-context, live-context, future envelope types). Audit: `process_file_drop` envelope dispatch, `drain_stale_init_signoffs` (Phase 30 deliver-then-die invariant), `drain_stale_signoff_file` (Phase 25.2 deliver-then-die). Also crosses Phase 25 D-10/D-11 two-slice routing semantics — if wrapper starts calling `route_two_slice` directly, downstream consumers must idempotently handle "envelope already routed before LLM saw it." Also affects Defect C investigation surface — wrapper-side relay is one suspect for the encoding leak.

  **C. Selective HTML-encoding leak in commune relay**
  - Root: User screenshot evidence (commune EVENT reaching Psyche at 16:58:53) shows outer `<EVENT type="commune">` and `<br>` tokens UNENCODED while inner `&lt;live-context&gt;` is ENCODED, all within the same payload body. Asymmetric encoding within a single envelope indicates a double-decode-or-not-decode bug somewhere in producer → spool → poll → wrapper → Psyche pipeline.
  - Primary investigation surface: `src/owl/send.rs` (deliver_body_anonymous encoding), `src/owl/echo_commune.rs` (EVENT body construction), EVENT-PART chunker (`src/owl/poll.rs` or sibling), spacetime-reorientation body-parsing rules (`<br>` split + `&lt;`→`<` decode chain ordering — apply `&amp;` last per documented contract).
  - **Blast radius:** every consumer that applies the body-parsing decode chain. If producers are emitting one-pass-pre-encoded bodies (inner tags encoded, outer EVENT raw), consumers expecting two-pass decode will under-decode; consumers expecting raw bodies will over-decode. Audit: poll listener decode, wrapper relay decode, Psyche prompt assembly, EVENT-PART reassembly across `<br>` tokens. Also affects Defect A fix — wrapper-side `route_two_slice` direct call needs to operate on decoded inner envelopes.

  **D. `psyche.md` never instructs Psyche to write `projects/<name>/<self_id>.md`**
  - Root: User-observed in Psyche session log — Psyche only ever writes `live_context.md`; never writes the project-scoped slice file at `projects/<project_name>/<self_id>.md`. The two-slice Phase 25 schema is wired on the producer/sentinel side (Phase 25.1) but the Psyche LLM's persistence instructions in `psyche.md` (embedded via `include_str!`) do not teach the LLM to split its absorption output into a general slice (`live_context.md`) plus project slices (`projects/<name>/<self_id>.md`).
  - Primary fix: `psyche.md` absorption rules must instruct the LLM to (a) detect `<project-context name="...">` envelopes in inbound payloads, (b) persist project-specific delta to `projects/<name>/<self_id>.md`, (c) keep `live_context.md` as the general slice — symmetric with the producer-side two-slice split taught in `commune.md`. Also clarify which `name` value to use (must match `last_project_name` stamping convention to avoid drift with Defect B2 fix).
  - **Blast radius:** every absorption path the Psyche LLM runs — commune absorption, signoff absorption, pulse absorption, init absorption, echo_commune fire (the LLM both consumes inbound context AND produces outbound two-slice envelopes). Audit all existing agents' Psyche dirs (`%LOCALAPPDATA%\spt\psyches\tracked\agents\<id>\`) to confirm none currently have populated `projects/` subtrees — confirms scope of "no agent has ever written this file." Also cross-check against Phase 25.1 `commune.md` / `signoff.md` teaching — psyche.md instruction must match skill-side teaching exactly or the human-written commune and LLM-emitted absorption will diverge. Also rebuild required: `psyche.md` is `include_str!`-embedded, so changes ship via binary handoff like any Rust change.

  ### Cross-cutting integration mandate

  Plans MUST treat B2 as standalone (smallest blast, fastest fix, unblocks correct `projects/<name>/` destination). D is paired with B2 — even with B2 fixed, the LLM still needs psyche.md instruction to actually write the file; B2 fix alone unblocks the path but D fix is what produces the artifact. A and C are coupled — both touch wrapper-side envelope handling and any fix to A that calls `route_two_slice` directly must first land C's decode contract clarification, else the wrapper routes encoded envelope bodies to writers that expect decoded ones.

  Plans:
  - [ ] 25.3-01-PLAN.md — Defect B2: project-name resolver fix in `build_current_context_blocks` (prefer info.json `last_project_name`, reject `"tracked"` D-06 false positive, audit shared callers)
  - [ ] 25.3-02-PLAN.md — Defect D: `psyche.md` absorption instruction — teach LLM to write `projects/<name>/<self_id>.md` symmetric with producer-side two-slice (commune.md/signoff.md alignment)
  - [ ] 25.3-03-PLAN.md — Defect C: encoding leak diagnosis + contract clarification across producer/poll/wrapper/Psyche decode chain
  - [ ] 25.3-04-PLAN.md — Defect A: wrapper-side envelope routing decision (direct `route_two_slice` vs psyche.md re-emit hardening) — depends on 25.3-03 contract


  ## Prior Review (Cycle 1)

  ---
  phase: 25.3
  reviewers: [codex]
  reviewed_at: 2026-05-22T17:55:00-07:00
  plans_reviewed: [25.3-01-PLAN.md, 25.3-02-PLAN.md, 25.3-03-PLAN.md, 25.3-04-PLAN.md]
  ---

  # Cross-AI Plan Review — Phase 25.3

  ## Codex Review

  ## Summary

  The plans are thoughtful and mostly sequenced around the right defect boundaries: B2 first, D paired with B2, C before A. The strongest part is that they treat this as a contract problem across resolver, prompt, encoding, and persistence layers rather than a single missing write. The main weakness is that several plans still contain unresolved architectural choices, contradictory envelope shapes, and test/deploy steps that may fail on Windows or mutate scope mid-plan. I would not execute these as-is without tightening the contracts first.

  ## 25.3-01 Plan Review

  ### Strengths

  - Correctly isolates B2 as the smallest first fix.
  - Uses `last_project_name` as the canonical project source, which matches Phase 24.1.
  - Includes caller audits for `build_current_context_blocks` and `derive_current_repo_names`.
  - Adds regression coverage for `tracked` false-positive handling.

  ### Concerns

  - **HIGH:** `resolve_self_project_stamp` still falls back to plain cwd `stamp()`, which can still produce `project="tracked"` for outbound EVENT attrs. That preserves the same asymmetry the plan is trying to remove.
  - **MEDIUM:** Rejecting literal `"tracked"` globally can break a real project named `tracked`. Better reject it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback.
  - **MEDIUM:** Tests that mutate `SPT_HOME` may be flaky under parallel `cargo test`. They need serial execution or isolated env handling.
  - **MEDIUM:** The plan says the prompt is keyed on `claude_skill_owl`, but the described prompt format only has `CURRENT_PROJECT_CONTEXT:` with no visible project name. That makes smoke verification ambiguous.
  - **LOW:** `files_modified` lists `tests/native_owl.rs`, but the task says to put most tests in the private module inside `echo_commune.rs`.

  ### Suggestions

  - Filter `"tracked"` in all three resolver layers: tracked info, legacy perch field, and cwd fallback.
  - Add a helper returning `Option<String>` for “safe resolved project name” and make both EVENT stamping and context-block resolution use it.
  - Make env-mutating tests serial.
  - Add a debug/test-only way to verify the resolved project name used for the prompt, not just the body path.

  ### Risk Assessment

  **MEDIUM.** The core fix is sound, but the remaining cwd fallback can still emit the bad project name and preserve asymmetry.

  ## 25.3-02 Plan Review

  ### Strengths

  - Correctly identifies that `psyche.md` must teach absorption, not just emission.
  - Aligns `psyche.md`, `commune.md`, and `signoff/SKILL.md`.
  - Calls out that `psyche.md` is embedded and requires rebuild.

  ### Concerns

  - **HIGH:** Option B expands far beyond listed files. It requires `build_current_context_blocks`, `parse_two_slice`, `route_two_slice`, validation, and tests, but the plan only lists prompt/skill files.
  - **HIGH:** The plan alternates between “Psyche writes `projects/<name>/<self_id>.md`” and “Psyche emits envelopes that Rust routes.” That distinction matters. The LLM should not be taught that it directly writes the file unless it actually has that tool path.
  - **HIGH:** The must-haves require `<project-context name="...">`, but Option A explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved.
  - **MEDIUM:** Deploying this before the encoding contract is fixed may still leave the Psyche seeing `&lt;project-context&gt;`, making the new absorption rules ineffective.
  - **MEDIUM:** LLM behavior is only verified by smoke testing. There should also be static prompt-contract tests or golden prompt checks.

  ### Suggestions

  - Pick Option A or B before finalizing the plan. My recommendation: defer Option B unless you are ready to change parser/writer semantics in this same phase.
  - Reword psyche instructions as: “emit this envelope; SPT routes it to disk.”
  - If Option B is chosen, make it a separate Rust implementation plan, not a prompt-doc task.
  - Move this after Plan 03, or explicitly state that Plan 03’s decoded-at-LLM-boundary contract is assumed.

  ### Risk Assessment

  **HIGH.** The plan’s unresolved envelope-shape decision can cause incompatible implementations across Plans 03 and 04.

  ## 25.3-03 Plan Review

  ### Strengths

  - Correctly treats encoding as a full-chain contract problem.
  - Adds a missing `event_body_unescape` inverse and specifies amp-last order.
  - Requires an end-to-end regression for the observed mixed raw/encoded state.
  - Explicitly hands off the body-state answer to Plan 04.

  ### Concerns

  - **HIGH:** Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope string. Otherwise user body content can become structural-looking `<EVENT>` text in the wrong layer.
  - **HIGH:** `depends_on: []` is risky because this plan depends on the envelope shape chosen in Plan 02. Plan 02 and 03 cannot both make independent assumptions.
  - **MEDIUM:** “Decode at exactly ONE site” is too rigid. Attr decoding already exists, and multiple final consumers may need body decoding at their own boundary. The better invariant is “one body decode per transport traversal.”
  - **MEDIUM:** The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary test.
  - **MEDIUM:** Several commands are Bash-style (`tail`, heredoc) despite the project’s Windows-native constraint and PowerShell shell.
  - **LOW:** A large contract doc in `poll.rs` may age poorly if encoding behavior spans modules. A small `encoding.rs` module would be cleaner if churn is expected.

  ### Suggestions

  - Define the contract around parsed fields: attrs decoded as attrs, body decoded only after body extraction.
  - Add tests for literal `</EVENT>`, literal `<br>`, amp entities, and EVENT-PART split/reassembly.
  - Replace smoke commands with PowerShell-compatible here-strings and `Get-Content -Tail`.
  - Make Plan 03 precede Plan 02, or require Plan 02’s envelope decision as an input.

  ### Risk Assessment

  **MEDIUM-HIGH.** The investigation-first structure is good, but an incorrect decode boundary would create framing or prompt-injection regressions.

  ## 25.3-04 Plan Review

  ### Strengths

  - Correctly waits for Plan 03 before routing inbound bodies.
  - Makes the key architecture choice explicit: deterministic wrapper route vs LLM round-trip.
  - Preserves LLM handoff if direct routing is chosen.
  - Treats route failure as nonfatal, which protects live-agent continuity.

  ### Concerns

  - **HIGH:** Option 1 ignores Plan 02 Option B’s possible `name=` attr. If named project-context envelopes are adopted, routing by receiver `last_project_name` may write cross-project context to the wrong file.
  - **HIGH:** Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body. The plan acknowledges this but does not mitigate it.
  - **HIGH:** Option 2 relies on prompt compliance for persistence, which is the failure mode that triggered this phase. It is weaker than the defect requires unless paired with enforcement or retry.
  - **MEDIUM:** “Route failures are nonfatal” is operationally safe but can hide persistence failure. It needs a clear warning/status surface.
  - **MEDIUM:** Tests require mocking `resume_session_with_exit` and `route_two_slice`, but the plan does not call out the necessary dependency injection or helper extraction.
  - **LOW:** `$OWL deliver todlando <<'EOF'` is not PowerShell-compatible.

  ### Suggestions

  - Prefer Option 1, but add stale-overwrite mitigation: after direct route, ensure the next prompt baseline includes the routed content and warn/error if haiku output omits project context.
  - If Option B from Plan 02 is chosen, route by validated `name=` attr; otherwise route by resolved `last_project_name`.
  - Add a monotonic append/merge or source precedence rule so LLM output cannot erase direct-routed inbound content accidentally.
  - Extract a small testable helper for “route inbound commune body” rather than testing through full wrapper process flow.

  ### Risk Assessment

  **HIGH** unless Option 1 is chosen with overwrite mitigation. Option 2 is too dependent on LLM compliance for a persistence defect.

  ## Cross-Plan Recommendations

  1. Resolve envelope shape first: no-name `<project-context>` vs named `<project-context name="X">`. This decision affects Plans 02, 03, and 04.
  2. Recommended order: Plan 01, Plan 03, Plan 02, Plan 04. Encoding state should be settled before prompt absorption rules and wrapper routing.
  3. Avoid deploying after every partial plan unless you intentionally want intermediate behavior live. A single integrated deploy after 01-03, then final deploy after 04, would reduce churn.
  4. Replace Bash commands with PowerShell equivalents throughout.
  5. Fix cargo filter commands. `cargo test` accepts one test filter; commands like `cargo test --release --lib resolve_self_project_stamp_prefers... read_tracked_last_project_name` are likely invalid.
  6. Add one phase-level invariant test: inbound body with live + project envelopes reaches final persistence with no `tracked` project, no encoded tags, and marker text in the expected project file.

  Overall phase risk: **HIGH as written**, mainly due to unresolved cross-plan contract choices. With the envelope shape chosen upfront, resolver fallback tightened, and decode boundary precisely defined, this drops to **MEDIUM**.

  ## Revised Plans (Cycle 2)

  ### 25.3-01-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - src/owl/echo_commune.rs
    - src/common/owlery.rs
    - Cargo.toml
  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-2 NOTE — codex review resolution map
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 01 section):
  #   HIGH (Plan 01): "resolve_self_project_stamp still falls back to plain cwd stamp(), which can still
  #     produce project='tracked' for outbound EVENT attrs. That preserves the same asymmetry the plan is
  #     trying to remove." → Task 1 + Task 2 (belt-and-braces 'tracked' filter at ALL THREE resolver layers:
  #     tracked-info, legacy perch field, cwd fallback).
  #   HIGH (locked decision #2 — NEW): "project name must derive from the cwd key in Self's perch info.json
  #     at owlery/<self_id>/info.json … resolve project-name from cwd via shared helper" → Task 0 audit +
  #     Task 1 new `project_name_from_cwd_path(&Path)` helper in owlery.rs + Task 2 wires it.
  #   MEDIUM: "Rejecting literal 'tracked' globally can break a real project named tracked. Better reject
  #     it only when the caller cwd is the SPT tracked psyche dir or when the value came from D-06 fallback."
  #     → Task 1: context-sensitive rejection (helper only filters when cwd resolves to the tracked psyche
  #     dir OR when the result came from the D-06 fallback branch). Tests cover the real-project-named-tracked
  #     non-regression case.
  #   MEDIUM: "Tests that mutate SPT_HOME may be flaky under parallel cargo test. They need serial execution
  #     or isolated env handling." → Task 1: add `serial_test` to Cargo.toml [dev-dependencies] and annotate
  #     env-mutating tests with `#[serial]`. The plan explicitly calls this out.
  #   MEDIUM: "The plan says the prompt is keyed on claude_skill_owl, but the described prompt format only
  #     has CURRENT_PROJECT_CONTEXT: with no visible project name. That makes smoke verification ambiguous."
  #     → Task 2: emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a test/debug-visible
  #     token, so smoke verification can grep the resolved name. (NB: this is purely a header annotation;
  #     LLM-side teaching in Plan 02 will be free to read or ignore the parenthetical.)
  #   LOW: "files_modified lists tests/native_owl.rs, but the task says to put most tests in the private
  #     module inside echo_commune.rs." → manifest fixed: tests live in the existing `#[cfg(test)] mod tests`
  #     blocks inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081) — no entry added to
  #     `tests/native_owl.rs`. files_modified updated.

  must_haves:
    truths:
      - "When Self's perch info.json `cwd` key resolves (via the new owlery helper) to a path inside `claude_skill_owl/`, `build_current_context_blocks` resolves the project to `claude_skill_owl` (NOT `tracked`, NOT psyche_dir basename, NOT raw process cwd)."
      - "When the haiku runs from cwd=psyche_dir for an agent inside `claude_skill_owl`, the emitted `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` block reads from `projects/claude_skill_owl/<self_id>.md` (or the first-commune literal), NEVER `projects/tracked/<self_id>.md`."
      - "The literal string `\"tracked\"` is rejected as a project name AT ALL THREE resolver layers (info.json `cwd`-derived → tracked-agents `last_project_name` → cwd `derive_current_repo_names` D-06 fallback) when the source path is the SPT tracked psyche dir, while real user projects literally named `tracked` continue to work (rejection is path-context-sensitive, not literal-global)."
      - "`resolve_self_project_stamp` (used at echo_commune.rs:869 for outbound EVENT stamping) and the new resolver used by `build_current_context_blocks` agree on the resolved project name in the same fire pass — no asymmetry between outbound envelope attrs and inner context-block content."
      - "Signoff prompt-build path (`crate::live::signoff::compose_init_signoff_payload`) shares the corrected resolver via `build_current_context_blocks` (Plan 04 DRY note at echo_commune.rs:397)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper (cwd-path → project name, mirrors `derive_current_repo_names()` resolution order but takes an explicit Path)."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Corrected `build_current_context_blocks` with info.json-cwd-derived project resolution + belt-and-braces `tracked` rejection at all three resolver layers."
        contains: "project_name_from_cwd_path"
      - path: "Cargo.toml"
        provides: "`serial_test` dev-dependency added so env-mutating tests can run serially under parallel `cargo test`."
        contains: "serial_test"
    key_links:
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "reads `cwd` key from Self's perch info.json (Phase 26 D5/Q1 field), passes Path to shared resolver"
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::project_name_from_cwd_path"
        via: "same path: read info.json cwd → resolver. Symmetry guarantee."
        pattern: "project_name_from_cwd_path"
      - from: "src/owl/echo_commune.rs::build_current_context_blocks"
        to: "src/common/owlery.rs::project_worktree_path"
        via: "join with `{self_id}.md` for CURRENT_PROJECT_CONTEXT body read"
        pattern: "project_worktree_path"
  ---

  <objective>
  Fix Defect B2 from `.planning/debug/todlando-project-context-not-persisted.md` per LOCKED DECISIONS:

  1. **Locked decision #2:** project name MUST derive from the `cwd` key in Self's perch `info.json` at `$SPT_HOME/owlery/<self_id>/info.json` (the `InfoJson.cwd` field — Phase 26 D5/Q1, `src/common/types.rs:110`). NOT from `tracked_agent_info.last_project_name` directly; NOT from process-global `std::env::current_dir()`. The cwd-key path is authoritative; downstream helpers resolve the project name from that path via shared logic.

  2. **Codex HIGH (belt-and-braces):** `"tracked"` must be filtered at ALL THREE resolver layers — info.json-cwd-derived, legacy perch field, and the cwd D-06 fallback inside `derive_current_repo_names()` consumers — but ONLY when the source path is the SPT tracked psyche dir, so a real user project literally named `tracked` continues to work.

  The haiku prompt-builder in `src/owl/echo_commune.rs::build_current_context_blocks` (line 411) currently calls `owlery::derive_current_repo_names()`, which reads process-global `std::env::current_dir()`. The haiku spawns with cwd = `psyche_dir()` per D-05 (Phase 18.8 invariant); that directory has no `.git` ancestor on a typical Windows install, so the D-06 fallback returns `["tracked"]` (the psyche dir basename). The haiku then sees `CURRENT_PROJECT_CONTEXT` keyed on project name `"tracked"` instead of the real project (`claude_skill_owl` for todlando), and `route_two_slice` writes to `projects/tracked/<self_id>.md` (or drops the envelope entirely because the LLM doesn't recognize `"tracked"` as a real project name).

  Meanwhile the same fire pass at line 869 calls `resolve_self_project_stamp(self_id)` which is SUPPOSED to read Self's project from info.json (lines 110-127) but reads the unused legacy perch `project` field and falls through to the same broken cwd stamp.

  **Investigation needed (Task 0):** confirm whether `project_name_from_cwd_path(&Path)` already exists in `src/common/owlery.rs` under some name. From the grep audit (planner side), no such helper exists — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)` (path-based, but private and not exposed). Task 0 explicitly audits + reports findings before Task 1 creates the helper. If a helper does exist, Task 1 reuses it.

  This plan:
  1. (Task 0) Audits `src/common/owlery.rs` for any existing cwd-path → project-name helper. Investigation only.
  2. (Task 1) Creates `project_name_from_cwd_path(path: &Path) -> Option<String>` in `owlery.rs` mirroring the resolution order of `derive_current_repo_names()` but taking an explicit Path. Refactors `resolve_self_project_stamp` to consume it (read perch info.json `cwd` → call helper). Adds belt-and-braces `"tracked"` rejection that fires ONLY when the source path is the SPT tracked psyche dir.
  3. (Task 2) Rewires `build_current_context_blocks` to consult the corrected resolver (same path as `resolve_self_project_stamp`) and emit `CURRENT_PROJECT_CONTEXT (name=<X>):` with the resolved name as a debug/test-visible token.
  4. (Task 3) Audits other callers of `build_current_context_blocks` (signoff prompt-build path) to confirm they pick up the fix transparently. Runs `cargo build --release` as the build-witness; defers smoke verification to Plan 02 (which lands psyche.md absorption + deploys).

  Purpose: Without this fix Defects D, A, and C are unobservable — the haiku never emits `<project-context>` because it doesn't know the project name. This is the smallest-blast standalone fix; ships first.

  Output:
  - `src/common/owlery.rs` — new `project_name_from_cwd_path` helper + made `find_git_root_basename` visible (pub(crate)) if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new helper.
  - `Cargo.toml` — `serial_test` dev-dependency added.
  - Unit tests in the existing `#[cfg(test)] mod tests` blocks in `src/owl/echo_commune.rs` and `src/common/owlery.rs`.
  - Binary rebuild (`cargo build --release`) — required because the haiku prompt-builder runs in the owl.exe process.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @src/owl/echo_commune.rs
  @src/common/owlery.rs
  @src/common/types.rs

  <interfaces>
  <!-- Key types and helpers extracted from src/common/owlery.rs + src/common/types.rs. -->

  From src/common/types.rs (lines 104-110, Phase 26 D5/Q1):
  ```rust
  pub struct InfoJson {
      // ... other fields
      /// Phase 26 D5/Q1: working directory the perch was created in.
      /// Used by the no-arg `/spt:live` picker for cross-repo collision detection
      /// (`pick-spec --resolve <name>`). Forward-slash-normalized for cross-platform
      /// stability. `None` on perches created before Phase 26 ships — treated as
      /// "unknown repo" by the owlery scan.
      #[serde(skip_serializing_if = "Option::is_none")]
      pub cwd: Option<String>,
      // ... other fields
  }
  ```

  From src/common/owlery.rs (line 204):
  ```rust
  pub fn info_file(id: &str) -> PathBuf;
  // $SPT_HOME/owlery/<id>/info.json — Self's PERCH info.json (NOT the tracked-agents one).
  // This is the one carrying the `cwd` field per locked decision #2.
  ```

  From src/common/owlery.rs (line 792, currently PRIVATE):
  ```rust
  fn find_git_root_basename(start: &std::path::Path) -> Option<String>;
  // Walks parents from `start` looking for `.git`; returns basename of the containing directory.
  // Already-correct primitive for the cwd→project resolver. Task 1 makes this `pub(crate)` so
  // the new public helper can reuse it without duplicating the walk.
  ```

  From src/common/owlery.rs (line 763):
  ```rust
  pub fn derive_current_repo_names() -> Vec<String>;
  // Process-global cwd resolver. Order: (a) find_git_root_basename(env::current_dir())
  // (b) git remote get-url origin basename (c) D-06 fallback to cwd.file_name().
  // The new helper mirrors (a)+(c) but accepts an explicit Path — same return shape (Option<String>).
  ```

  From src/owl/echo_commune.rs (current state — what the fix replaces):
  - Line 110-127: `fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp`
    - Currently reads PERCH info.json `project` field via `read_self_project_from_info` (line 132).
    - That field is never written → always returns None → falls to `stamp()` cwd basename.
  - Line 399-423: `pub(crate) fn build_current_context_blocks(self_id: &str) -> String`
    - Calls `owlery::derive_current_repo_names().first()` at line 411.
    - Does NOT consult info.json AT ALL — bypasses resolve_self_project_stamp.
  - Line 869: outbound EVENT stamping uses `resolve_self_project_stamp(self_id)` — gets cwd basename today; will resolve correctly after Task 1.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Audit owlery.rs for any existing cwd-path → project-name helper</name>
    <files>(read-only investigation; no edits)</files>
    <action>
      Run greps:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub fn project_name_from|fn derive_project|fn project_name_for|fn project_for_path' -SimpleMatch
      Select-String -Path src/common/*.rs -Pattern 'fn .*project.*_from_|fn .*project_name' -SimpleMatch
      ```
      Also grep across `src/common/` for any helper that takes `&Path` and returns `Option<String>` describing a project name.

      Document findings inline in the SUMMARY:
      - If a helper exists with matching semantics → Task 1 reuses it (record file:line).
      - If `find_git_root_basename` is the closest thing → Task 1 builds the new public helper around it.
      - If nothing exists → Task 1 creates from scratch.

      Decision rule (no checkpoint required — autonomous): if the audit finds a helper that already accepts &Path and resolves project name with at least the (a)+(c) resolution order from `derive_current_repo_names()`, reuse it verbatim and name it as such in Task 1. Otherwise create `project_name_from_cwd_path` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd' | Measure-Object | Select-Object -ExpandProperty Count</automated>
    </verify>
    <done>
      - Audit complete; helper inventory recorded in SUMMARY.
      - Task 1 has an unambiguous decision: reuse-existing OR create-new.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 1: Create project_name_from_cwd_path helper + refactor resolve_self_project_stamp + add serial_test dep</name>
    <files>src/common/owlery.rs, src/owl/echo_commune.rs, Cargo.toml</files>
    <behavior>
      - `project_name_from_cwd_path(Path::new("C:/Users/decid/Documents/projects/claude_skill_owl/some/sub"))` returns `Some("claude_skill_owl")` (resolves via parent walk to the `.git` ancestor at `claude_skill_owl/`).
      - `project_name_from_cwd_path(Path::new("$SPT_HOME/psyches/tracked"))` returns `None` (path-context-sensitive `tracked` rejection — the path resolves via D-06 fallback to basename `tracked` AND the path equals `crate::common::owlery::psyche_dir()`; reject only in that specific case).
      - `project_name_from_cwd_path(Path::new("/home/user/projects/tracked"))` where `tracked/` IS a real `.git` repo returns `Some("tracked")` (real-project-named-tracked non-regression).
      - `project_name_from_cwd_path(Path::new("/nonexistent/path"))` returns `None` gracefully (no panic).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns a stamp with `project = "claude_skill_owl"` (NOT `tracked`).
      - `resolve_self_project_stamp("todlando")` when info.json is missing OR `cwd` field is None → falls through to legacy perch field → finally falls through to `crate::common::git::stamp()` cwd-based — BUT with the same `tracked` rejection guard wrapped around the final fallback (belt-and-braces).
      - Real-project-named-tracked: if a user genuinely has a repo named `tracked` and their process cwd is inside it, `stamp()` returns `project = "tracked"` AND the post-stamp guard does NOT strip it (because the guard only fires when the resolved path equals psyche_dir, not when the name equals literal `"tracked"`).
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version — check what other plans pin) under `[dev-dependencies]`. Tests in Task 1 + Task 2 that mutate `SPT_HOME` will be annotated with `#[serial]`.

      Step 2 — `src/common/owlery.rs`: make `find_git_root_basename` `pub(crate)` (currently private at line 792). Add a new public helper directly below it (around line 800):

      ```rust
      /// Path-based cwd → project name resolver. Mirrors `derive_current_repo_names()`
      /// (D-03 (a) + (c)) but takes an EXPLICIT `&Path` instead of reading process-global
      /// `std::env::current_dir()`.
      ///
      /// Resolution order:
      ///   (a) Walk parents from `path` looking for `.git`; if found, return that
      ///       directory's file_name() as the local basename. (Uses `find_git_root_basename`.)
      ///   (b) If no `.git` is found, fall back to `path.file_name()` as the basename
      ///       (D-06 fallback equivalent).
      ///   (c) Path-context-sensitive `tracked` rejection (25.3-01 Defect B2): if the
      ///       resolved name is the literal `"tracked"` AND `path` canonicalizes to the
      ///       SPT tracked psyche dir (`psyche_dir()`), return None. Real user projects
      ///       literally named `tracked` are NOT rejected (their resolved path lies
      ///       outside psyche_dir).
      ///
      /// Returns None when `path` has no file_name component (filesystem root).
      pub fn project_name_from_cwd_path(path: &std::path::Path) -> Option<String> {
          let name = find_git_root_basename(path)
              .or_else(|| path.file_name().and_then(|s| s.to_str()).map(String::from))?;
          // 25.3-01 Defect B2: path-context-sensitive `tracked` rejection.
          // Real user projects literally named "tracked" are NOT rejected — only the
          // SPT psyche_dir basename collision is filtered.
          if name == "tracked" && is_within_tracked_psyche_dir(path) {
              return None;
          }
          Some(name)
      }

      /// Helper: returns true iff `path` (after canonicalization, best-effort)
      /// is the SPT tracked psyche dir or a child of it. Used by
      /// `project_name_from_cwd_path` to reject the D-06 false positive without
      /// rejecting real user projects literally named "tracked".
      fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool {
          let psyche = psyche_dir();  // existing helper; returns $SPT_HOME/psyches/tracked
          let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
          let psyche_canon = psyche.canonicalize().unwrap_or(psyche);
          path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)
      }
      ```

      Step 3 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127):

      ```rust
      fn resolve_self_project_stamp(self_id: &str) -> crate::common::git::Stamp {
          let mut s = crate::common::git::stamp();

          // 25.3-01 Defect B2 (locked decision #2): resolve project from Self's
          // perch info.json `cwd` key (Phase 26 D5/Q1) via the shared helper.
          // This is the AUTHORITATIVE source. Legacy perch `project` field and
          // process cwd are fallbacks only.
          if let Some(name) = resolve_self_project_name_via_info_cwd(self_id) {
              s.project = name;
              return s;
          }

          // Legacy fallback (preserved for forward compat — if a future phase
          // populates the perch `project` field, this path still works).
          if let Some(p) = read_self_project_from_info(self_id) {
              if !p.is_empty() && p != "tracked" {
                  // Belt-and-braces: filter literal "tracked" here too.
                  // Real-project-named-tracked is acceptable collateral at this
                  // layer because the legacy perch `project` field is never
                  // written by any current code path; if a future phase writes
                  // it, that phase is responsible for the value.
                  s.project = p;
                  return s;
              }
          }

          // Final fallback: cwd-relative stamp. Belt-and-braces filter: if the
          // stamp's project is "tracked" AND we can verify via process cwd that
          // we're inside psyche_dir, leave s.project as a NON-project sentinel.
          // BUT preserve real-project-named-tracked: only filter when the
          // current process cwd is psyche_dir.
          if s.project == "tracked" {
              if let Ok(cwd) = std::env::current_dir() {
                  if crate::common::owlery::project_name_from_cwd_path(&cwd).is_none() {
                      // Helper agrees this is the D-06 false positive; clear it.
                      s.project = String::new();
                  }
              }
          }
          s
      }

      /// 25.3-01 Defect B2 (locked decision #2): primary resolver for Self's
      /// project name. Reads Self's perch info.json (Phase 26 `cwd` field),
      /// passes the path to the shared `project_name_from_cwd_path` resolver.
      fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String> {
          let info_path = crate::common::owlery::info_file(self_id);
          let content = std::fs::read_to_string(&info_path).ok()?;
          let info: crate::common::types::InfoJson =
              serde_json::from_str(&content).ok()?;
          let cwd_str = info.cwd?;
          let cwd_path = std::path::PathBuf::from(&cwd_str);
          crate::common::owlery::project_name_from_cwd_path(&cwd_path)
      }
      ```

      Remove the TODO comment at lines 108-109 (now fulfilled).

      Step 4 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s) inside `src/owl/echo_commune.rs` (lines 1127, 1703, 1894, 2081 — choose whichever is the resolver-/info-shaped block; if no fit, add to the line-1127 block). Tests in `src/common/owlery.rs` go in that file's existing `mod tests`.

      Mark every test that mutates `SPT_HOME` with `#[serial_test::serial]`.

      Tests:
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — create temp dir with `.git/` marker; assert helper returns the dir's basename.
      - `owlery::tests::project_name_from_cwd_path_falls_back_to_basename_when_no_git` — temp dir without `.git`; assert helper returns the basename.
      - `owlery::tests::project_name_from_cwd_path_rejects_psyche_dir_basename` — set `SPT_HOME` to a temp; pass `psyche_dir()` as path; assert returns None. `#[serial]`.
      - `owlery::tests::project_name_from_cwd_path_accepts_real_project_named_tracked` — temp dir literally named `tracked/.git/`; assert helper returns Some("tracked"). `#[serial]` (sets SPT_HOME to a different temp to ensure psyche_dir does NOT equal the project path).
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write a fake perch info.json with cwd pointing to a temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_falls_through_to_legacy_when_cwd_missing` — info.json without `cwd` field; assert legacy path is consulted.
      - `echo_commune::tests::resolve_self_project_stamp_clears_tracked_at_final_fallback` — info.json missing entirely, process cwd is psyche_dir → assert `stamp.project` is empty (NOT `tracked`). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_preserves_real_project_named_tracked` — info.json missing, process cwd is a temp dir literally named `tracked/.git/` OUTSIDE psyche_dir → assert `stamp.project == "tracked"`. `#[serial]`.

      Build witness: `cargo build --release` at end of task.
    </action>
    <verify>
      <automated>cargo test --release project_name_from_cwd_path 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, pub.
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` reads info.json `cwd` first via `resolve_self_project_name_via_info_cwd`.
      - All three resolver layers (info-cwd, legacy perch field, final cwd-stamp) filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces).
      - Real-project-named-tracked test passes (regression guard).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - 8 unit tests pass; `#[serial]` annotations present on env-mutating ones.
      - `cargo build --release` succeeds.
      - TODO at echo_commune.rs:108-109 removed.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Rewire build_current_context_blocks + emit debug-visible project name token</name>
    <files>src/owl/echo_commune.rs</files>
    <behavior>
      - `build_current_context_blocks("todlando")` invoked when Self's perch info.json `cwd` resolves to `claude_skill_owl/` produces a prompt containing `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` followed by the content of `projects/claude_skill_owl/todlando.md` (or first-commune literal).
      - Same call when info.json `cwd` is missing AND process cwd resolves to psyche_dir (D-06 returns "tracked") produces a prompt with NO `CURRENT_PROJECT_CONTEXT` block.
      - Same call when info.json `cwd` is missing AND process cwd resolves to a real repo (e.g. `my_real_repo/`) produces a prompt with `CURRENT_PROJECT_CONTEXT (name=my_real_repo):` keyed on `my_real_repo`.
      - Outbound EVENT stamping at line 869 and inner-block resolution at line 411 BOTH consume the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). No asymmetry.
      - The `(name=<X>)` parenthetical is purely a debug/test-visible token — Plan 02 will teach the LLM how to consume it (or to ignore it).
    </behavior>
    <action>
      Read `src/owl/echo_commune.rs` lines 360-425.

      Replace the project resolution at line 411 with the same layered resolver used by `resolve_self_project_stamp`:

      ```rust
      pub(crate) fn build_current_context_blocks(self_id: &str) -> String {
          let live_path = owlery::agent_worktree_path(self_id).join("live_context.md");
          let live_body = std::fs::read_to_string(&live_path)
              .unwrap_or_else(|_| "(none — first commune)".to_string());

          // 25.3-01 Defect B2 (locked decision #2): authoritative resolver
          // (info.json cwd → project_name_from_cwd_path). Symmetric with
          // resolve_self_project_stamp at line 869.
          let resolved_project: Option<String> = resolve_self_project_name_via_info_cwd(self_id)
              .or_else(|| {
                  // Legacy/process-cwd fallback (kept for callers like
                  // signoff prompt-build that may run from a real project cwd).
                  owlery::derive_current_repo_names().into_iter().next().and_then(|name| {
                      // Belt-and-braces (codex HIGH): filter literal "tracked"
                      // here too, but only when the process cwd resolves to
                      // psyche_dir.
                      if name == "tracked" {
                          let cwd = std::env::current_dir().ok()?;
                          if owlery::project_name_from_cwd_path(&cwd).is_none() {
                              return None;
                          }
                      }
                      Some(name)
                  })
              });

          let project_block = match resolved_project {
              None => String::new(),
              Some(name) => {
                  let proj_path =
                      owlery::project_worktree_path(&name).join(format!("{}.md", self_id));
                  let body = std::fs::read_to_string(&proj_path)
                      .unwrap_or_else(|_| "(none — first commune in project)".to_string());
                  // Codex MEDIUM: debug/test-visible name token in the header.
                  // Plan 02 will teach the LLM how to consume or ignore the
                  // parenthetical.
                  format!("\n\nCURRENT_PROJECT_CONTEXT (name={}):\n{}", name, body)
              }
          };

          format!("CURRENT_LIVE_CONTEXT:\n{}{}", live_body, project_block)
      }
      ```

      Update the doc comment on `build_current_context_blocks` (lines 372-397) to:
      - Cite Defect B2 fix and the new resolver order (info-cwd → process-cwd-with-tracked-filter → omit).
      - Keep the existing Plan 04 Task 4 DRY note about signoff prompt builder.
      - Add: "Outbound EVENT stamping at run_echo_commune line 869 (`resolve_self_project_stamp`) consumes the same authoritative path (info.json `cwd` → `project_name_from_cwd_path`). The header's `(name=<X>)` token is debug/test-visible and is the SAME `X` that appears in the outbound `<EVENT project=\"X\">` attr in the same fire pass — assert this symmetry in tests."

      Unit tests in the existing `#[cfg(test)] mod tests` block:
      - `build_current_context_blocks_uses_info_json_cwd_for_project` — write fake perch info.json with cwd pointing to a temp .git repo; assert prompt contains `CURRENT_PROJECT_CONTEXT (name=<repo_basename>):`. `#[serial]`.
      - `build_current_context_blocks_omits_block_when_info_cwd_resolves_to_psyche_dir` — info.json `cwd = psyche_dir`; assert NO `CURRENT_PROJECT_CONTEXT` substring. `#[serial]`.
      - `build_current_context_blocks_emits_name_token_in_header` — assert the exact substring `CURRENT_PROJECT_CONTEXT (name=` appears when a project is resolved. `#[serial]`.
      - `build_current_context_blocks_first_commune_literal_when_project_file_missing` — info.json resolves project but `projects/<name>/<self_id>.md` is absent; assert body is `(none — first commune in project)`. `#[serial]`.
      - `build_current_context_blocks_and_resolve_self_project_stamp_agree` — same fire-pass invariant: both call sites return the SAME project name string. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release build_current_context_blocks 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `build_current_context_blocks` consults `resolve_self_project_name_via_info_cwd` first.
      - Header format is `CURRENT_PROJECT_CONTEXT (name=<X>):` when project resolves.
      - Belt-and-braces `tracked` filter on the process-cwd fallback layer.
      - 5 unit tests pass, all `#[serial]`-annotated.
      - Doc comment updated with Defect B2 attribution + symmetry invariant.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Caller audit + binary rebuild (smoke deferred to Plan 02 deploy)</name>
    <files>(read-only audit; build witness only)</files>
    <action>
      Step 1 — Grep audit (PowerShell):
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'build_current_context_blocks|derive_current_repo_names|resolve_self_project_stamp' -SimpleMatch
      ```
      For each call site, classify:
      - Haiku prompt builder (echo_commune.rs:411) — fixed in Task 2.
      - Signoff prompt builder — confirm it calls `build_current_context_blocks` (transparently fixed) or has its own resolver (port the fix).
      - Outbound EVENT stamping (echo_commune.rs:869) — fixed in Task 1.
      - Anything else — investigate.

      Step 2 — Read `src/live/signoff.rs::compose_init_signoff_payload`. Confirm it invokes `build_current_context_blocks(self_id)` and does NOT have a divergent project-resolution path. If divergent, port Task 2 logic OR refactor to call `build_current_context_blocks`. Document the path taken in SUMMARY.

      Step 3 — Inspect every remaining `derive_current_repo_names()` call site. For each, classify as:
      (a) Not a haiku/signoff prompt builder (D-06 fallback is intentional — e.g. listing tools that genuinely want cwd-based names), OR
      (b) Already protected against the `tracked` false positive in some other way, OR
      (c) NEEDS the same fix → flag in SUMMARY for follow-up Plan if any.

      Step 4 — Rebuild the binary:
      ```powershell
      cargo build --release
      ```
      Captures binary witness only. Smoke verification (does the artifact materialize?) is DEFERRED to Plan 02, which lands psyche.md absorption rules and performs the deploy.

      Step 5 — Write a one-paragraph "Plan 02 handoff note" in the SUMMARY stating: "After this plan, the haiku prompt is keyed on the correct project name, but the haiku LLM may still drop the `<project-context>` envelope (Defect D). Full end-to-end artifact materialization requires Plan 02."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Caller audit complete; SUMMARY documents each `build_current_context_blocks` and `derive_current_repo_names` site with disposition.
      - `cargo build --release` succeeds.
      - Plan 02 handoff note written.
      - NO deploy in this plan (per cross-plan recommendation: single integrated deploy after Plans 01+03).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Self's perch info.json read (`InfoJson.cwd` field) | Filesystem read of JSON the SPT runtime itself writes at perch creation time. |
  | Process cwd read (fallback layer) | Process-global state; safe for our own process. |
  | Haiku LLM prompt construction | Prompt assembled in Rust before being piped to claude subprocess stdin. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.01-T1 | Tampering | `cwd` field in Self's perch info.json | accept | Field is written by SPT at perch creation (Phase 26 D5/Q1) under SPT_HOME (user-controlled); local tampering is in-scope-for-user. The path is consumed read-only — never executed, never joined into a filesystem write destination by this plan. |
  | T-25.3.01-I1 | Information disclosure | Project name (resolved from cwd path) leaked into haiku prompt | accept | Same value already appears in outbound EVENT envelope attrs (Phase 23 D-14), info.json files on disk, and stamping helpers. No new disclosure surface. |
  | T-25.3.01-D1 | Denial of service | Malformed perch info.json crashes the resolver | mitigate | `resolve_self_project_name_via_info_cwd` returns Option via `.ok()?` chain; JSON parse failures → None → falls through to legacy/cwd layers. No panic path. Tested via `info_json_malformed_falls_through` (add if missing). |
  | T-25.3.01-T2 | Tampering | `cwd` field set to a path containing a poison basename (e.g. `..`, `/`, or shell metachars) | mitigate | `project_name_from_cwd_path` uses `Path::file_name()` (returns last normalized component) and `find_git_root_basename` (walks parents); both are pure string/path manipulations — no shell evaluation, no exec. The returned name flows ONLY into `project_worktree_path(&name)` which already joins under `tracked_root()/projects/` with no path traversal escape. |
  | T-25.3.01-T3 | Tampering | Real-project-named-tracked confusion (user has legitimate `tracked` repo) | mitigate | Path-context-sensitive rejection: filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit test `project_name_from_cwd_path_accepts_real_project_named_tracked` guards this. |
  | T-25.3.01-SC | Tampering (supply chain) | `serial_test` crate (new dev-dep) | mitigate | `serial_test` is a widely-used crate (millions of downloads on crates.io). RESEARCH.md package audit not strictly required for dev-only deps, but: verify via `https://crates.io/crates/serial_test` before adding. Pin to a major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW) is the perch-info-driven entry point.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback.
     - `crate::common::git::stamp()` cwd basename is the tertiary fallback, with `tracked` filter.
     - All four layers are exercised by unit tests.

  2. Grep audit complete: both `build_current_context_blocks` and `derive_current_repo_names` callers documented.

  3. Binary build witness: `cargo build --release` succeeds.

  4. Tests pass: `cargo test --release` (full suite) — no regressions on existing stamping tests (Phase 23 / 24.1).

  5. `serial_test` dev-dep added; env-mutating tests annotated `#[serial]`.
  </verification>

  <success_criteria>
  - `project_name_from_cwd_path(path)` resolves the correct project name when called with Self's perch `cwd` path.
  - `resolve_self_project_stamp("todlando")` returns `project="claude_skill_owl"` when info.json `cwd` is set correctly.
  - `build_current_context_blocks("todlando")` emits `CURRENT_PROJECT_CONTEXT (name=claude_skill_owl):` (debug-visible token).
  - Belt-and-braces `tracked` filter at all three resolver layers; real-project-named-tracked non-regression preserved.
  - `cargo test --release` passes including 13 new tests; no regressions.
  - `cargo build --release` succeeds.
  - NO deploy from this plan (deferred to Plan 02).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` when done. Include:
  - Task 0 audit findings (existing helper or none).
  - Resolver layer diagram (info-cwd → legacy-perch → cwd-stamp), each with its `tracked` filter point.
  - Caller audit table for `build_current_context_blocks` and `derive_current_repo_names`.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note.
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>

  ---

  ### 25.3-02-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 02
  type: execute
  wave: 3
  depends_on:
    - 25.3-01
    - 25.3-03
  files_modified:
    - psyche.md
    - plugin/spt/skills/commune/commune.md
    - plugin/spt/skills/signoff/SKILL.md
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 3 (was wave 2), AFTER 03. Absorption rules
  # can only be written authoritatively once we know the body-state at LLM-stdin (Plan 03 contract).
  #
  # This plan addresses the following codex HIGH/MEDIUM concerns from 25.3-REVIEWS.md (Plan 02 section):
  #   HIGH (locked decision #1): "The must-haves require `<project-context name='...'>`, but Option A
  #     explicitly keeps no name attr. The plan is internally inconsistent until that decision is resolved."
  #     → RESOLVED. Envelope shape is LOCKED to Option A: no-name `<project-context>`. NO `name=` attr
  #     anywhere in this plan. All occurrences updated. Producer/consumer envelope shape contract is
  #     unchanged from pre-25.3.
  #   HIGH: "Option B expands far beyond listed files. It requires build_current_context_blocks,
  #     parse_two_slice, route_two_slice, validation, and tests, but the plan only lists prompt/skill files."
  #     → RESOLVED by Option A lock. parse_two_slice / route_two_slice are NOT modified. build_current_context_blocks
  #     is changed by Plan 01 (header annotation only, not envelope contract). Files in this plan are strictly
  #     psyche.md + skill files.
  #   HIGH: "The plan alternates between 'Psyche writes projects/<name>/<self_id>.md' and 'Psyche emits
  #     envelopes that Rust routes.' That distinction matters. The LLM should not be taught that it directly
  #     writes the file unless it actually has that tool path." → Task 2 reworded throughout: "emit this
  #     envelope; SPT routes it to disk." Psyche's job = emit. SPT's job = route. No path-writing teaching.
  #   HIGH (resolved by reorder): "depends on Plan 03's encoding contract for body-state at LLM-stdin
  #     boundary." → `depends_on: [25.3-01, 25.3-03]`. Task 0 reads Plan 03 SUMMARY to lift the body-state
  #     answer into psyche.md.
  #   MEDIUM: "Deploying this before the encoding contract is fixed may still leave the Psyche seeing
  #     `&lt;project-context&gt;`, making the new absorption rules ineffective." → RESOLVED by wave order.
  #     This plan deploys ONLY AFTER 03 lands; Plan 03's encoding fix ships in the same deploy.
  #   MEDIUM: "LLM behavior is only verified by smoke testing. There should also be static prompt-contract
  #     tests or golden prompt checks." → Task 2 Step 4 adds a golden prompt check: snapshot the `<absorption>`
  #     section content; test verifies psyche.md contains the canonical block (line count + key substrings).
  #     Not a full LLM-output golden, but a static prompt-contract check.

  must_haves:
    truths:
      - "When the Psyche LLM receives an inbound payload containing `<project-context>...</project-context>` (Option A locked shape — NO `name=` attr), it knows from psyche.md that it should merge the body into its in-memory project context and re-emit on the next context-save fire. SPT (Rust) routes the body to `projects/<name>/<self_id>.md`; the LLM is NOT taught any filesystem path."
      - "On every cadence-driven context save (every 3-5 messages per existing rule), the Psyche LLM emits BOTH `<live-context>` AND (when in-project) `<project-context>` envelopes — producing the two-slice output that `route_two_slice` writes to disk."
      - "The Psyche LLM does NOT attempt to derive or re-emit a project name token — `<project-context>` is no-name throughout (Option A locked). SPT (build_current_context_blocks per Plan 01) handles all project-name resolution."
      - "`plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` reference the same Option-A no-name `<project-context>` shape and use the phrasing 'emit this envelope; SPT routes it to disk' (NOT 'Psyche writes the file')."
      - "After Plan 03's encoding fix + this plan's psyche.md absorption rules + consolidated deploy, an existing todlando-style agent absorbs and re-emits `<project-context>` on the next echo_commune fire, and `route_two_slice` creates `projects/claude_skill_owl/todlando.md` on disk."
      - "psyche.md absorption rules state explicitly that they assume the Plan 03 encoding contract (body arrives at LLM-stdin in <encoded|decoded> state per Plan 03 SUMMARY's chosen Option) — so future editors can't accidentally re-encode."
    artifacts:
      - path: "psyche.md"
        provides: "Absorption + emission rules for two-slice envelope. New `<absorption>` section using Option A no-name shape and 'SPT routes to disk' phrasing."
        contains: "<project-context>"
      - path: "plugin/spt/skills/commune/commune.md"
        provides: "Producer-side teaching aligned with psyche.md absorption rules (no-name shape; 'SPT routes' phrasing)."
        contains: "project-context"
      - path: "plugin/spt/skills/signoff/SKILL.md"
        provides: "Signoff-side teaching aligned with psyche.md absorption rules."
        contains: "project-context"
    key_links:
      - from: "psyche.md (LLM emit instruction)"
        to: "src/owl/echo_commune.rs::route_two_slice"
        via: "no-name `<project-context>` envelope; Rust resolves destination project via Plan 01's resolver"
        pattern: "project-context"
      - from: "psyche.md absorption rules"
        to: "src/owl/poll.rs (Plan 03 encoding contract)"
        via: "body-state-at-LLM-stdin assumption (encoded or decoded per Plan 03 chosen Option)"
        pattern: "ENCODING CONTRACT"
  ---

  <objective>
  Fix Defect D from `.planning/debug/todlando-project-context-not-persisted.md`. `psyche.md` (the LLM prompt embedded into owl.exe via `include_str!`) currently teaches the Psyche LLM to emit `<live-context>` and `<project-context>` envelopes at context-save time (Phase 25.1 work at commit ff1d935), BUT user-observed behavior on todlando is that the Psyche only writes `live_context.md` — never the `project-context` envelope — even when `CURRENT_PROJECT_CONTEXT` is present in the prompt.

  Inspection of `psyche.md` confirms existing teaching (lines 244-354) covers both envelopes, but TWO gaps remain:

  1. **Absorption side gap (the primary fix):** Teaching is heavily emit-shaped. There is no explicit rule that says "when an INBOUND payload contains `<project-context>...</project-context>`, treat its body as project-context delta and merge it into your in-memory context." The architecture relies on round-trip-via-LLM — the Psyche merges in-memory and the NEXT echo_commune fire writes the merged envelope to disk via SPT routing. When inbound envelopes are silently ignored, content is lost.

  2. **Routing-clarity gap (codex HIGH):** Existing teaching is ambiguous about WHO writes the file. The corrected teaching is: "**emit this envelope; SPT routes it to disk**" — the LLM's job is EMIT, SPT's job is ROUTE. The LLM is not taught any filesystem path; route_two_slice (Plan 25 Plan 04) is the routing surface, not the LLM.

  Per LOCKED DECISIONS:
  - **Locked decision #1:** envelope shape is Option A (no-name `<project-context>`). NO `name=` attr anywhere. Producer/consumer envelope shape contract is UNCHANGED from pre-25.3. Minimal blast radius — no parser/writer changes.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (body-state-at-LLM-stdin). This plan's absorption rules reference that contract by name so future editors don't accidentally re-encode.
  - **Consolidated deploy:** This plan owns the integrated deploy of Plans 01+03+02 (single DEPLOY.ps1 invocation). Plan 04 has its own subsequent deploy.

  This plan cross-aligns `plugin/spt/skills/commune/commune.md` and `plugin/spt/skills/signoff/SKILL.md` so the human-side teaching matches the LLM-side teaching exactly. Skill files sync via DEPLOY; psyche.md ships via binary rebuild.

  Output:
  - `psyche.md` — strengthened absorption rules + Option A no-name shape + "SPT routes" phrasing + Plan 03 contract reference.
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - Binary rebuild + DEPLOY.ps1 -Bump patch (single consolidated deploy for Plans 01+03+02).
  - Operator-verify checkpoint confirming `projects/claude_skill_owl/todlando.md` appears on disk after the deploy.

  Purpose: Plan 01 made the haiku see the right project name. Plan 03 made the body uniformly decoded at LLM stdin. Plan 02 makes the haiku LLM actually emit `<project-context>` AND merge inbound. Without all three, the artifact never materializes.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @psyche.md
  @plugin/spt/skills/commune/commune.md
  @plugin/spt/skills/signoff/SKILL.md

  <interfaces>
  <!-- Key prompt-side contracts the Psyche LLM consumes. -->

  From psyche.md (current state, lines 245-354):
  - `<output_envelope>` section teaches `<live-context>` + `<project-context>` emission (line 287-322).
  - Line 269: "If the prompt has NO CURRENT_PROJECT_CONTEXT block (cwd outside any tracked project), emit ONLY the `<live-context>` envelope and omit `<project-context>` entirely."
  - Line 317: rule 2 — "A block whose body is the literal `(none — first commune in project)` still counts as PRESENT — emit both envelopes."
  - Line 322: receivers split output and route by `<cwd_project>` — STALE PHRASING; replace.
  - `<context_save>` section (line 325-355) reinforces emit-every-3-5-messages.

  What is MISSING (this plan adds):
  - No `<absorption>` section teaching the LLM to detect and merge INBOUND `<project-context>` envelopes (no-name shape).
  - No rule that says "SPT routes the emit to disk; you do not name the project or know any path."
  - No reference to the Plan 03 encoding contract (body-state-at-LLM-stdin).

  From Plan 01 outcome (already landed by execution time):
  - `build_current_context_blocks` emits `CURRENT_PROJECT_CONTEXT (name=<X>):` header. The `(name=<X>)` is DEBUG/test-visible only — the LLM does NOT need to read it for routing purposes (SPT handles routing). The LLM MAY note it as context but MUST NOT emit it as a `name=` attr in `<project-context>` (Option A locked: no name attr).

  From Plan 03 outcome (already landed by execution time, read SUMMARY):
  - Body state at LLM stdin = <encoded|decoded> per chosen Option (Plan 03 Task 2). This plan's psyche.md absorption rules reference that state by name.

  From Plan 25 envelope code (UNCHANGED in this plan):
  - `parse_two_slice` reads literal `<live-context>` / `<project-context>` tags (no-name shape).
  - `route_two_slice` writes to `projects/<resolved_name>/<self_id>.md`, resolving `<resolved_name>` via Plan 01's helper at write time. The LLM never names the project.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 01 + Plan 03 SUMMARYs and extract handoff contracts</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md` and `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract for the psyche.md edits:
      - From Plan 01: the header format that `build_current_context_blocks` now emits — exactly `CURRENT_PROJECT_CONTEXT (name=<X>):` per Plan 01 Task 2. psyche.md absorption rule will instruct the LLM: "the parenthetical `(name=<X>)` is purely informational; you MUST NOT echo it back as a `name=` attr."
      - From Plan 03: the body state at LLM stdin (encoded vs decoded). psyche.md will state this explicitly so future editors don't re-encode. The exact wording goes into a new "Encoding assumption" subsection of `<absorption>`.

      Record both findings inline in this plan's SUMMARY for traceability.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-SUMMARY.md; Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 01 header format extracted.
      - Plan 03 body-state-at-LLM-stdin extracted.
      - Both recorded in this plan's SUMMARY for downstream traceability.
    </done>
  </task>

  <task type="auto">
    <name>Task 2: Add absorption + emission rules to psyche.md (Option A locked; 'SPT routes' phrasing)</name>
    <files>psyche.md</files>
    <action>
      Read `psyche.md` lines 240-360 (the `<output_envelope>` and `<context_save>` sections).

      Add a NEW `<absorption>` section AFTER `<output_envelope>` and BEFORE `<context_save>`, titled "Absorbing inbound two-slice envelopes". Rules:

      1. **Live-context absorption:** When an inbound payload (commune, signoff, pulse) contains `<live-context>...</live-context>`, treat the body as live-context delta and merge into your in-memory live context. On the next context-save fire, re-emit it inside `<live-context>...</live-context>` (existing rule — restate explicitly here for symmetry).

      2. **Project-context absorption:** When an inbound payload contains `<project-context>...</project-context>` (NO name attr — locked Option A shape), treat the body as project-context delta and merge into your in-memory project context. On the next context-save fire, re-emit it inside `<project-context>...</project-context>`.

      3. **Inbound merge is always active.** You do not need a CURRENT_LIVE_CONTEXT or CURRENT_PROJECT_CONTEXT prompt block to absorb inbound. Those blocks supply the BASELINE; inbound envelopes supply DELTAS layered on top.

      4. **SPT routes; you emit.** When you emit a `<project-context>...</project-context>` envelope, SPT (the Rust runtime) writes the body to `projects/<resolved_project>/<self_id>.md` and resolves `<resolved_project>` via Self's perch info.json `cwd` field (25.3-01 Defect B2 fix). You do NOT name the project; you do NOT know the filesystem path; the envelope shape is no-name throughout. If the prompt's `CURRENT_PROJECT_CONTEXT (name=<X>):` header carries an `(name=<X>)` parenthetical, treat it as informational only — DO NOT echo it as a `name=` attr.

      5. **Encoding assumption (25.3-03 contract reference).** At the LLM-stdin boundary, the EVENT envelope body is in <STATE> form per Plan 03's chosen Option (read 25.3-03-SUMMARY.md Plan-04-handoff section; insert the chosen state literal here at execute time — typically "decoded" so `<live-context>` and `<project-context>` are LITERAL tags). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume.

      Update the existing `<output_envelope>` rule at psyche.md line 322 — remove the `<cwd_project>` phrasing entirely and replace with: "Receivers split the two-slice output by tag name (`<live-context>` and `<project-context>`); SPT resolves the destination project via Self's perch info.json (25.3-01 fix) — the LLM does NOT emit a project name."

      Update the `<context_save>` section (line 325-355) similarly — replace any `cwd_project` mentions; ensure emit rule is "emit `<project-context>` whenever a CURRENT_PROJECT_CONTEXT block (with or without name parenthetical) is present in the prompt".

      Do NOT add new pulse-scheduling rules, new tool permissions, or new persistence locations. This task is strictly the two-slice absorption + emission teaching + Option A shape clarification + encoding assumption.

      Keep total prompt growth under +70 lines (psyche.md is context-window-critical for every Psyche session).

      Step 4 — Static prompt-contract check (codex MEDIUM). Add a tiny test in `tests/native_owl.rs` (or `tests/psyche_md_contract.rs`):
      ```rust
      #[test]
      fn psyche_md_contains_25_3_absorption_section() {
          let content = include_str!("../psyche.md");
          assert!(content.contains("<absorption>"), "psyche.md must have <absorption> section");
          assert!(content.contains("Absorbing inbound two-slice envelopes"), "section title missing");
          assert!(content.contains("SPT routes"), "must teach 'SPT routes' phrasing (codex HIGH)");
          assert!(!content.contains("<project-context name="), "Option A locked: no name= attr");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("encoding contract"), "must reference Plan 03 contract");
      }
      ```
      This is a static prompt-contract test — not a full LLM-output golden, but it guards against accidental phrase drift in future edits.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'project-context' | Measure-Object).Count; (Select-String -Path psyche.md -Pattern 'live-context' | Measure-Object).Count; (Get-Content psyche.md | Measure-Object -Line).Lines; cargo test --release psyche_md_contains_25_3_absorption_section 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists with rules 1-5.
      - Option A no-name shape used throughout — NO `name=` attr anywhere.
      - "SPT routes; you emit" phrasing present (codex HIGH).
      - Encoding assumption references Plan 03 (codex HIGH dependency).
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - Static prompt-contract test passes.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: Cross-align commune.md and signoff/SKILL.md with psyche.md teaching (Option A; 'SPT routes')</name>
    <files>plugin/spt/skills/commune/commune.md, plugin/spt/skills/signoff/SKILL.md</files>
    <action>
      Read both files in full.

      For each file, locate the section that teaches how to compose a commune/signoff body. Update:

      1. **Option A no-name shape (LOCKED):** producer emits `<project-context>...</project-context>` (NO `name=` attr) when the cwd is inside a tracked project. Remove any `name=` examples if present.

      2. **'SPT routes' phrasing (codex HIGH):** both files use the wording "emit this envelope; SPT routes it to disk." NOT "Psyche writes the file." NOT "the agent persists to the project file." The producer does not name the project; SPT resolves it via Self's perch info.json `cwd` field (25.3-01).

      3. **Routing destination naming:** both files reference the correct Phase 25 schema: `projects/<resolved_project>/<self_id>.md`. Old references to `projects/<self_id>.md` (flat layout — the user's mistaken expectation per the debug doc) must be corrected. Make explicit that `<resolved_project>` is resolved by SPT, not by the agent.

      4. **Cross-reference psyche.md absorption rules:** add a one-line pointer in each file: "Psyche absorbs and re-emits these envelopes per psyche.md `<absorption>` rules (25.3-02)."

      Do NOT introduce new commune subcommands, flags, or file-drop conventions. Strictly documentation alignment.

      `commune.md` is the larger surface — start there. `signoff/SKILL.md` is typically thinner; only update the project-context envelope section.

      File sync: skill files deploy via DEPLOY.ps1 (Task 4); no binary rebuild needed for skill changes.
    </action>
    <verify>
      <automated>Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'project-context' -SimpleMatch; Select-String -Path plugin/spt/skills/commune/commune.md,plugin/spt/skills/signoff/SKILL.md -Pattern 'name=' -SimpleMatch | Select-Object -First 5</automated>
    </verify>
    <done>
      - Both skill files reference the Phase 25 two-slice schema (`projects/<resolved_project>/<self_id>.md`).
      - Both cross-reference psyche.md absorption rules.
      - No-name shape used throughout (NO `name=` examples).
      - "SPT routes" phrasing present.
      - No new subcommands / flags / file-drop conventions introduced.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Consolidated deploy (Plans 01+03+02) + smoke trace</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo build --release` to embed the new psyche.md into owl.exe (Plans 01 + 03 changes already committed; rebuild picks up all three plans' edits).

      Step 2 — Bump plugin patch version per CLAUDE.md DEPLOY contract:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      This syncs owl.exe + plugin/spt/skills/* + plugin.json to the cplugs marketplace clone, refreshes the plugin cache, runs `claude plugin install spt@cplugs`. This is the CONSOLIDATED deploy for Plans 01+03+02.

      Step 3 — STOP. Per CLAUDE.md, DO NOT run `/reload-plugins`. The user will run it.

      Step 4 — Once the user has reloaded plugins (and existing live perches have self-migrated per Phase 18.4/18.5 binary handoff), trigger an echo_commune fire on todlando:
      - Send a commune via `/spt:commune` from the user side OR wait for the wrapper's natural cadence fire.
      - Tail `%LOCALAPPDATA%\spt\logs_latest\todlando.log` (PowerShell):
        ```powershell
        Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200 -Wait
        ```
        (run_in_background: true since the agent perch is active.)
      - Look for the next `[PSYCHE] resume (exit=0):` line and inspect the haiku output for BOTH `<live-context>` AND `<project-context>` envelopes (literal `<` and `>`, NOT `&lt;`).

      Step 5 — Confirm artifact creation on disk (PowerShell, NO Bash):
      ```powershell
      Get-ChildItem "$env:LOCALAPPDATA\spt\psyches\tracked\projects\"
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      The file MUST exist with non-empty `<project-context>` body content. If it does not, this is a blocking-human-verify failure — the LLM may need additional prompt tightening, or Plan 04's wrapper-direct route may be required.

      Step 6 — Encoding-contract smoke (codex MEDIUM Plan 03 verification, run here since Plan 03 had no deploy):
      From the same tailed log, confirm the body content delivered to Psyche stdin shows uniform encoding state per Plan 03's chosen Option. NO mixed state.
    </action>
    <verify>
      <automated>Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - `cargo build --release` succeeds.
      - DEPLOY.ps1 -Bump patch succeeds; plugin version bumped (consolidated deploy of Plans 01+03+02).
      - User runs `/reload-plugins`; existing todlando wrapper self-migrates to new binary.
      - Next echo_commune fire produces BOTH envelopes in haiku output (log-confirmed; literal tags, no `&lt;`).
      - `projects/claude_skill_owl/todlando.md` exists on disk with `<project-context>` body content.
      - Encoding-contract smoke confirms uniform state (NO mixed encoding).
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Plans 01 + 03 + 02 together (consolidated):
      - Plan 01 (B2): corrected project-name resolver via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; consolidated deploy.

      Net outcome at this point: outbound `<project-context>` envelopes from the haiku materialize as `projects/<resolved_project>/<self_id>.md` on disk. Inbound envelope absorption is now taught but persistence still depends on the round-trip-via-LLM path (Plan 04 will optionally add wrapper-direct routing).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`).
      2. Open it and confirm the body contains useful project-scoped context content (not the first-commune literal, not empty).
      3. Optional: send a deliberate commune containing `<project-context>fresh content marker xyz</project-context>` to todlando using PowerShell here-string:
         ```powershell
         $body = @'
         <project-context>fresh content marker xyz</project-context>
         '@
         $env:OWL --% deliver todlando "$body"
         # (or use a temp file: $body | Set-Content -NoNewline tmp.txt; $env:OWL deliver todlando --file tmp.txt)
         ```
         Wait for the next echo_commune fire, then re-open the project context file and confirm the marker text appears.
      4. Confirm `live_context.md` for todlando still updates as expected (no regression on the live slice).
      5. Read the log tail and confirm the haiku output contains literal `<project-context>` (not `&lt;project-context&gt;`) — Plan 03 verification.
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform, or describe what's wrong / missing.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound envelope → Psyche LLM | LLM consumes payload sent from another agent (Self listener, peer perch); body is user-controlled prose. |
  | Psyche LLM emit → route_two_slice writer | LLM-generated envelope body is parsed and written to disk by Rust code. |
  | Skill files in marketplace clone | Deploy syncs `plugin/spt/skills/*` to the user's plugin cache via DEPLOY.ps1. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.02-T1 | Tampering | `name=` attr in inbound `<project-context name=...>` (HYPOTHETICAL — would only exist if Option B were chosen; Option A LOCKS this surface to non-existent) | accept | Option A locked: no-name shape throughout. Future re-introduction of name attr would require a new phase; the static prompt-contract test (`psyche_md_contains_25_3_absorption_section`) asserts no `<project-context name=` substring exists in psyche.md. |
  | T-25.3.02-I1 | Information disclosure | Project name leakage between agents via the `(name=<X>)` parenthetical in prompt header | accept | Same disclosure surface as outbound EVENT envelope attrs (Phase 23 D-14). The parenthetical is in the prompt input only; the LLM is taught not to echo it back. |
  | T-25.3.02-D1 | Denial of service | Malformed inbound envelope crashes Psyche LLM session | mitigate | psyche.md absorption rule explicitly says "if envelope parse fails, continue without merge and log to live-context body" — never abort the session. Defense-in-depth at the LLM layer; route_two_slice already has Rust-side defenses. |
  | T-25.3.02-T2 | Tampering | LLM is tricked into emitting `<project-context name="../../../etc/passwd">` via prompt-injection | mitigate | Option A locked — the LLM is explicitly taught NOT to emit any `name=` attr. SPT-side: even if the LLM disobeyed, `parse_two_slice` ignores attributes (no-name shape parser); routing uses SPT-resolved project name (Plan 01 info.json `cwd` field), never user content. Path traversal blocked twice. |
  | T-25.3.02-SC | Tampering (supply chain) | n/a (no package installs in this plan) | n/a | No npm/pip/cargo installs — pure documentation + Rust rebuild. Cargo.lock not modified except by deterministic rebuild. DEPLOY.ps1 is in-repo signed script. |
  </threat_model>

  <verification>
  - `cargo build --release` succeeds (incorporates Plans 01+03+02 source changes into owl.exe).
  - DEPLOY.ps1 runs successfully; plugin version bumped (consolidated deploy).
  - After user reload-plugins, next echo_commune fire produces BOTH envelopes in haiku output with LITERAL inner tags (no `&lt;`).
  - `projects/claude_skill_owl/todlando.md` exists with non-empty `<project-context>` body.
  - `live_context.md` for todlando continues to update normally.
  - Static prompt-contract test passes.
  - Encoding-contract smoke confirms uniform state at LLM-stdin (Plan 03 verification).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing.
  - commune.md + signoff/SKILL.md aligned with the same shape and phrasing.
  - Binary deployed; in-flight perches handed off.
  - `projects/<resolved_project>/<self_id>.md` artifact materializes on disk for todlando after one echo_commune cycle.
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract test asserts no `<project-context name=` substring (Option A guard).
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-SUMMARY.md` when done. Include:
  - Plan 01 + Plan 03 handoff facts (header format + body-state-at-LLM-stdin) used in the psyche.md edits.
  - psyche.md diff summary (line counts before/after, sections added/modified).
  - commune.md + signoff/SKILL.md diff summary.
  - DEPLOY.ps1 output excerpt (plugin version bump, marketplace push).
  - Smoke trace: log excerpt (PowerShell `Get-Content -Tail`) showing both envelopes in haiku output with LITERAL inner tags.
  - Artifact verification: PowerShell `Get-ChildItem` of `projects/` directory contents + first 20 lines of `todlando.md`.
  - Operator-verify response.
  - Codex review resolution table: each HIGH/MEDIUM addressed → task that closes it → test/check that verifies it.
  - Plan 04 handoff: "Inbound envelope absorption rules are in place; round-trip-via-LLM persistence is functional. Plan 04 EITHER adds wrapper-direct routing for resilience (Option 1) OR locks in the LLM round-trip with re-emit hardening (Option 2)."
  </output>

  ---

  ### 25.3-03-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 25.3-01
  files_modified:
    - src/owl/poll.rs
    - src/owl/echo_commune.rs
    - src/live/wrapper/mod.rs
    - src/live/commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 2 (was wave 2 originally but
  # logically AFTER 02; cycle-2 puts it BEFORE 02 because 02's psyche.md absorption rules need
  # the body-state-at-LLM-stdin-boundary answer that 03 produces).
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 03 section):
  #   HIGH: "Decoding must happen after EVENT framing is parsed, not by globally unescaping the full envelope
  #     string. Otherwise user body content can become structural-looking <EVENT> text in the wrong layer."
  #     → Task 1 trace + Task 3 fix: decode order is (1) parse EVENT framing first (attrs decoded as attrs,
  #     body extracted as raw substring), (2) decode body content only AFTER body extraction. Documented in
  #     the new contract block. Tests cover bodies containing literal `</EVENT>`, `<live-context>` nested,
  #     `<br>` literals.
  #   HIGH: "depends_on: [] is risky because this plan depends on the envelope shape chosen in Plan 02."
  #     → RESOLVED by locked decision #1 (Option A — no-name `<project-context>`). Plus reorder: 03 now
  #     precedes 02. `depends_on: [25.3-01]` (needs Plan 01's helper for resolver-aware encoding tests).
  #   MEDIUM: "'Decode at exactly ONE site' is too rigid. Attr decoding already exists, and multiple final
  #     consumers may need body decoding at their own boundary. The better invariant is 'one body decode per
  #     transport traversal.'" → Contract reframed in Task 3 step 3: "One body decode per transport
  #     traversal. Multiple final consumers MAY each apply their own decode at their own boundary, but a
  #     single body MUST NOT traverse two consecutive decode passes within one traversal."
  #   MEDIUM: "The plan mentions EVENT-PART chunking as blast radius but does not require a chunk-boundary
  #     test." → Task 3 Step 5 adds: split/reassembly tests where body content contains literal `</EVENT>`,
  #     `<br>`, and `&amp;` entities AT the chunk boundary.
  #   MEDIUM: "Several commands are Bash-style (tail, heredoc) despite the project's Windows-native
  #     constraint and PowerShell shell." → Task 4 uses PowerShell: `Get-Content -Tail`, PowerShell
  #     here-strings `@'...'@`, `$env:LOCALAPPDATA`. No Bash heredocs.
  #   LOW: "A large contract doc in poll.rs may age poorly if encoding behavior spans modules. A small
  #     encoding.rs module would be cleaner if churn is expected." → Task 3 Step 3 decision: if the contract
  #     doc exceeds ~80 lines OR introduces new helpers beyond `event_body_unescape`, EXTRACT to new
  #     `src/owl/encoding.rs` module. Otherwise inline in `poll.rs`. The decision threshold is documented
  #     so it's deterministic.

  must_haves:
    truths:
      - "Every emit/parse site in the producer → spool → poll → wrapper → Psyche chain agrees on a single, written-down encoding contract — attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most ONE body decode per transport traversal."
      - "When a commune EVENT envelope arrives at the Psyche LLM stdin, the body is either CONSISTENTLY encoded (outer EVENT framing escaped and inner `<live-context>` tags ALSO escaped throughout) or CONSISTENTLY decoded (outer framing already consumed by parser; inner `<live-context>` / `<project-context>` tags readable literal). NEVER the mixed state observed at 16:58:53 (outer raw + inner encoded)."
      - "If the producer side is over-encoding inner envelopes (double-encode), that bug is fixed at the source. If a wrapper relay path is adding a second encode, it is removed (relays are pure pass-throughs)."
      - "A new written-down decode contract lives in a single doc surface (`src/owl/poll.rs` top-of-module comment OR new `src/owl/encoding.rs` if the contract exceeds 80 lines / introduces new helpers)."
      - "EVENT-PART chunk-boundary tests cover bodies containing literal `</EVENT>`, `<br>`, and `&amp;` entities at split/reassembly boundaries — no chunk-boundary regression."
      - "User body content containing literal `</EVENT>` at any layer NEVER becomes structural (framing-injection blocked at producer encode; decode happens AFTER framing-parse so user `</EVENT>` cannot reframe upstream)."
    artifacts:
      - path: "src/owl/poll.rs (or src/owl/encoding.rs if extracted)"
        provides: "Documented encode/decode contract block (top-of-module comment) covering all EVENT envelope sites. NEW `event_body_unescape` helper (inverse of `event_body_escape`)."
        contains: "ENCODING CONTRACT"
      - path: "tests/native_owl.rs"
        provides: "End-to-end test: producer emits → spool stores → poll parses → wrapper relays → Psyche-bound body; assert no asymmetric encoding state; EVENT-PART chunk-boundary tests."
        contains: "encoding_chain"
    key_links:
      - from: "src/owl/poll.rs::event_body_escape (producer side)"
        to: "wrapper Psyche-bound delivery (consumer side)"
        via: "EVENT envelope body; one encode at producer, one decode AFTER body extraction at consumer"
        pattern: "event_body_escape|event_body_unescape"
      - from: "src/owl/echo_commune.rs::compose_echo_commune_payload"
        to: "src/live/wrapper/mod.rs::resume_session_with_exit (delivery to Psyche LLM stdin)"
        via: "EVENT envelope traversal; encoding contract holds across spool + wrapper relay"
        pattern: "compose_echo_commune_payload"
  ---

  <objective>
  Fix Defect C from `.planning/debug/todlando-project-context-not-persisted.md` and the ROADMAP entry. User screenshot evidence (16:58:53 commune EVENT reaching Psyche) shows asymmetric HTML encoding within a single payload: outer `<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` is ENCODED — all in the same body.

  That mixed state is impossible under a consistent contract.

  Per LOCKED DECISIONS:
  - **Plan reorder:** This plan now executes BEFORE Plan 02. Plan 02's psyche.md absorption rules need to know the body-state-at-LLM-stdin-boundary, which is THIS plan's output.
  - **Codex HIGH on decode boundary:** the contract is "attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most one body decode per transport traversal." NOT a global `String::replace` unescape across the entire envelope string.

  This plan:
  1. (Task 1) Traces the encoding chain at every relevant site (producer, spool persistence, poll, wrapper relay, Psyche stdin handoff). Identifies the asymmetric site.
  2. (Task 2) Records the encoding-contract direction decision (consumer sees encoded vs decoded) — checkpoint:decision.
  3. (Task 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the chosen consumer boundary; remove any double-encodes. Writes the contract doc. Adds tests including EVENT-PART chunk-boundary regressions.
  4. (Task 4) Build witness only — `cargo build --release` + `cargo test --release` full suite. NO deploy in this plan. Single integrated deploy lands in Plan 02 (after Plans 01+03 both finish).

  Note on Plan 04 dependency: Plan 04 (Defect A wrapper inbound routing) DEPENDS on this plan's contract — the wrapper's `process_file_drop` cannot safely call `route_two_slice` on an inbound envelope until we know whether the body is encoded or decoded at that point. Task 1's chain trace + Task 2's chosen option supplies Plan 04's input.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @src/owl/echo_commune.rs
  @src/owl/poll.rs
  @src/owl/send.rs
  @src/live/wrapper/mod.rs
  @src/live/commune.rs

  <interfaces>
  <!-- Encode/decode primitives in current code. Extracted from src/owl/poll.rs:681-739. -->

  ```rust
  // src/owl/poll.rs:685
  pub(crate) fn event_body_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
          .replace('\n', "<br>")
  }

  // src/owl/poll.rs:700
  pub(crate) fn event_attr_escape(s: &str) -> String {
      s.replace('&', "&amp;")
          .replace('<', "&lt;")
          .replace('>', "&gt;")
          .replace('"', "&quot;")
  }

  // src/owl/poll.rs:720 (parse_event_from_attr — inverse on the from= attr only)
  pub(crate) fn parse_event_from_attr(msg: &str) -> Option<String>;
  // Unescape order: &lt;, &gt;, &quot;, then &amp; LAST.
  ```

  Producer call sites:
  - src/owl/echo_commune.rs:74-83 — compose_echo_commune_payload: attrs + body escape.
  - src/owl/poll.rs:1058-1067 — alarm + plain msg EVENT compose.
  - src/owl/poll.rs:1139-1151 — file-drop EVENT compose: attr escape only.
  - src/live/start.rs:183-186 — init/listener composition.
  - src/live/commune.rs:30 — commune body escape.
  - src/common/git.rs:169-190 — Stamp::event_attrs.

  Decode sites:
  - src/owl/poll.rs:720 — parse_event_from_attr (from= attr only).
  - src/live/wrapper/mod.rs:347 — inverse of event_attr_escape (also from-attr decoder).
  - (?) body decode — NO body decode site currently exists. THIS IS THE CONTRACT GAP.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Full encoding chain trace + asymmetry root-cause investigation</name>
    <files>(read-only investigation — no edits)</files>
    <action>
      Run PowerShell-friendly greps:
      ```powershell
      Select-String -Path src/**/*.rs -Pattern 'event_body_escape|event_attr_escape|event_body_unescape|parse_event_from_attr' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern '&amp;|&lt;|&gt;|&quot;|<br>' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'deliver_body|deliver_body_anonymous|deliver_body_deferred' -SimpleMatch
      Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch
      ```

      For each emit site (every `format!`/`write!` that constructs an EVENT envelope):
      - Note file:line.
      - Note what is escaped (attrs / body / both / neither).
      - Note where the result is sent (TCP write / spool insert / stdin write).

      For each consumer site:
      - Note file:line.
      - Note what is decoded (attrs / body / both / neither).
      - Note destination (LLM stdin / Self prompt inject / written to file).

      Trace ONE inbound commune end-to-end:
      1. User runs `/spt:commune` → writes `.claude/<id>-commune.md` (file-drop).
      2. Self listener (poll.rs) detects file, composes a `file_drop` EVENT envelope (poll.rs:1139-1151).
      3. Self listener delivers via owl REST to Psyche perch.
      4. Psyche wrapper (`src/live/wrapper/mod.rs`) receives file_drop EVENT, parses kind+path, reads file contents (raw filesystem read — no encoding), composes `commune` EVENT via `compose_commune_payload(timestamp, stamp, body)` — INSPECT THIS HELPER.
      5. Wrapper sends composed envelope to Psyche LLM stdin via `resume_session_with_exit`.

      Trace the OUTBOUND echo_commune path:
      1. Wrapper invokes `compose_echo_commune_payload(from, ts, stamp, note, body)` — body via `event_body_escape`.
      2. Result delivered via `send::deliver_body_anonymous` to Psyche AND/OR `send::deliver_body` to Self.

      For EACH step record: encoded? decoded? pass-through? Identify the asymmetric site.

      Produce a CHAIN TABLE in the SUMMARY:
      | Step | File:line | Operation | Input state | Output state |
      | 1 | poll.rs:1149 | file_drop EVENT compose | raw file path attr | attr-escaped path; body N/A |
      | 2 | wrapper:1247 | std::fs::read_to_string | raw on disk | raw in memory |
      | 3 | wrapper:1279 | compose_commune_payload | raw body | (TBD) |
      | 4 | wrapper:1318 | resume_session_with_exit stdin write | (TBD) | LLM sees (TBD) |

      Inspect `compose_commune_payload` (find via the grep above — likely in `src/live/wrapper/mod.rs` or sibling). Document its body-encoding behavior exactly.

      State the root-cause hypothesis explicitly and testably in SUMMARY.
    </action>
    <verify>
      <automated>(Select-String -Path src/**/*.rs -Pattern 'compose_commune_payload|compose_echo_commune_payload|compose_init_signoff_payload' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Chain table built in SUMMARY for inbound commune and outbound echo_commune paths.
      - `compose_commune_payload` body-encoding behavior documented exactly.
      - Asymmetric site identified by file:line with specific reason.
      - Root cause hypothesis stated, testable.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Body-decode boundary — where does the body get decoded to literal form?</decision>
    <context>
      Per locked-decision context (Plan 02 absorption rules need this answer): the Psyche LLM must SEE literal `<live-context>` and `<project-context>` tags (not `&lt;` / `&gt;`). So at SOME consumer-side boundary, the body must be decoded.

      The producer-side encode is necessary because outer `<EVENT>` framing must be unambiguous in spool storage / TCP transport (literal `<EVENT>` inside the body would corrupt framing).

      Per codex HIGH: decode happens AFTER EVENT framing is parsed (NOT a global string unescape). The question is WHERE the body-decode step lives:

      Option A — Decode in the wrapper just before LLM stdin write. Single canonical decode site. Spool/TCP/wrapper-inner-poll all carry encoded bodies. Easiest to audit.

      Option B — Decode at the poll site (Self listener boundary). Spool/TCP carry encoded; everything downstream of poll sees decoded bodies. Easier for downstream code but breaks framing through the wrapper relay (if relay re-encodes for retransmit, double-encode regression risk).

      Option C — No decode anywhere; teach LLM to handle `&lt;live-context&gt;` literally. Rejected (breaks Plan 02 absorption rules).

      Plan 04 (Defect A wrapper routing) depends on this: if wrapper calls `route_two_slice` on inbound envelope, body MUST be decoded first so `parse_two_slice` can find literal `<live-context>`. Choice here determines whether Plan 04's decode happens inside `process_file_drop` (Option A) or whether body is already decoded before reaching process_file_drop (Option B).
    </context>
    <options>
      <option id="option-a">
        <name>Decode at wrapper LLM-stdin boundary (after body extraction)</name>
        <pros>Single canonical decode point; spool + TCP + wrapper all carry encoded bodies (easy framing); matches outbound producer-encode-once symmetry; Plan 04 can decode in process_file_drop.</pros>
        <cons>Plan 04 wrapper routing also needs a decode pass before `route_two_slice`; small duplication (one extra call to `event_body_unescape`).</cons>
      </option>
      <option id="option-b">
        <name>Decode at poll site (Self listener boundary)</name>
        <pros>Everything downstream of poll sees decoded bodies; Plan 04 wrapper routing gets decoded body for free.</pros>
        <cons>Spool + TCP + wrapper relay must NOT re-encode; risk of framing corruption if any downstream emit accidentally treats the body as encoded; harder to audit.</cons>
      </option>
    </options>
    <resume-signal>Select: option-a or option-b. Subsequent tasks fix the asymmetric site to match.</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site, write contract doc, add tests including chunk-boundary</name>
    <files>src/owl/poll.rs, src/owl/echo_commune.rs, src/live/wrapper/mod.rs, src/live/commune.rs, tests/native_owl.rs</files>
    <behavior>
      - The asymmetric site found in Task 1 is corrected to match the chosen option from Task 2.
      - New helper `event_body_unescape` (inverse of `event_body_escape`) added to `src/owl/poll.rs` if it does not already exist. Decode order: `<br>`→`\n` FIRST, then `&lt;`→`<`, `&gt;`→`>`, `&quot;`→`"`, `&amp;`→`&` LAST (amp-last invariant — Pitfall 3 inverse, mirrors `parse_event_from_attr` precedent at poll.rs:720).
      - Body decode invoked at the chosen consumer boundary, AFTER EVENT framing has been parsed (NOT a global string-unescape of the entire envelope string).
      - Round-trip symmetry: `event_body_unescape(event_body_escape(s)) == s` for bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>`.
      - EVENT-PART chunk-boundary regression: a body containing literal `</EVENT>`, `<br>`, and `&amp;` SPLIT across EVENT-PART chunks (Phase 27 chunker) reassembles correctly and decodes cleanly with no mid-chunk corruption.
      - Producer-side double-encodes (if any) removed at the source — NOT compensated downstream.
      - Wrapper relays are pure pass-throughs (no re-encode unless composing a fresh EVENT envelope).
      - Codex MEDIUM reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site"). Multiple final consumers may each decode at their own boundary.
    </behavior>
    <action>
      Step 1 — Add `event_body_unescape` to `src/owl/poll.rs` (immediately after `event_body_escape` at line 691):

      ```rust
      /// Inverse of `event_body_escape`. Decode order MUST be `<br>` first, then
      /// tag-shaped entities, with `&amp;`→`&` LAST (Pitfall 3 inverse — same
      /// invariant as `parse_event_from_attr` at poll.rs:720).
      ///
      /// 25.3-03 Defect C: this is the body-side counterpart to the existing
      /// attr-side inverse. Call site invariant: invoke ONLY after EVENT framing
      /// has been parsed (i.e. body has been extracted as a substring); NEVER
      /// apply to the full envelope string globally — that would let user body
      /// content containing literal `</EVENT>` reframe upstream layers.
      ///
      /// Invariant: at most ONE body decode per transport traversal.
      pub(crate) fn event_body_unescape(s: &str) -> String {
          s.replace("<br>", "\n")
              .replace("&lt;", "<")
              .replace("&gt;", ">")
              .replace("&quot;", "\"")
              .replace("&amp;", "&")  // amp-last (Pitfall 3 inverse)
      }
      ```

      Step 2 — Fix the asymmetric site identified in Task 1. The fix is ONE of (choose per Task 1 findings):
      (a) Remove a duplicate encode at producer (most likely: `compose_commune_payload` is encoding a body that was already encoded upstream).
      (b) Add the missing decode at the chosen consumer boundary (`resume_session_with_exit` for Option A; `poll.rs` receive path for Option B) — AFTER body extraction.
      (c) Remove an upstream encode that should not exist (e.g. `src/live/commune.rs:30` encoding a body that flows into wrapper relay that ALSO encodes).

      Document the change with a comment block citing Defect C and pointing to the SUMMARY trace table.

      Step 3 — Document the chosen contract. Default location: top-of-module comment in `src/owl/poll.rs`. EXTRACTION TRIGGER: if the contract block exceeds 80 lines OR if Step 2's fix introduces new helpers beyond `event_body_unescape`, EXTRACT to new `src/owl/encoding.rs` module and re-export the existing escape helpers from there (preserve all call sites). Otherwise inline in `poll.rs`.

      Block must cover:
      - Where encoding happens (every producer emit site, file:line).
      - Where decoding happens (the chosen consumer boundary; amp-last invariant; AFTER body extraction).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see (encoded throughout OR encoded up to poll only, per chosen option).
      - Reframed invariant: "one body decode per transport traversal" (NOT "decode at exactly one site").
      - Forbidden combinations: no double-encode; no global string unescape of the full envelope; no decode BEFORE body extraction.

      Step 4 — Unit tests in `tests/native_owl.rs` (use ONE `cargo test` filter per invocation per cross-plan recommendation):
      - `event_body_unescape_inverts_event_body_escape_for_html_content`
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` (body contains `</EVENT>` — must NOT reframe)
      - `event_body_unescape_inverts_event_body_escape_for_literal_br_in_body` (body contains literal `<br>` text)
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` (covers the asymmetric site directly).

      Step 5 — EVENT-PART chunk-boundary tests (NEW per codex MEDIUM):
      - `event_part_chunk_boundary_with_literal_end_event_in_body` — split a body containing `</EVENT>` mid-chunk; assert reassembly is byte-identical; decode is clean.
      - `event_part_chunk_boundary_with_br_token_at_split` — `<br>` straddles the chunk boundary; assert reassembly preserves the literal `<br>` token.
      - `event_part_chunk_boundary_with_amp_entity_at_split` — `&amp;` straddles the chunk boundary; assert reassembly + amp-last decode produces a single `&`.

      Locate the EVENT-PART chunker (Phase 27) via grep `EVENT-PART|EventPart|split_into_chunks` and use the existing test scaffolds.

      Step 6 — End-to-end test (new file `tests/encoding_chain_end_to_end.rs` OR extend `tests/native_owl.rs` — pick by existing test-file conventions):
      - Fabricate a commune body containing literal `<live-context>...</live-context>` AND literal `<project-context>...</project-context>`.
      - Run through full pipeline using test scaffolds (or stubs): poll-side compose → spool write → spool read → wrapper relay → final body that hits Psyche stdin.
      - Assert: final body has NO `&lt;` / `&gt;` / `&quot;` / `&amp;` entities AND inner envelope tags are literal `<` / `>`. NO MIXED state (the regression-witness for the original Defect C bug).
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `event_body_unescape` exists in `src/owl/poll.rs` with amp-last invariant.
      - Asymmetric site fixed at the source (NOT compensated downstream).
      - Single source-of-truth contract doc block exists (in `poll.rs` or extracted to `encoding.rs` per the 80-line threshold).
      - Reframed invariant present: "one body decode per transport traversal."
      - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
      - No regressions on existing tests.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Full test suite + cargo build witness (NO deploy in this plan)</name>
    <files>Cargo.lock</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm zero regressions on existing golden tests, handoff integration tests, hook chain tests). Use ONE filter at a time if scoping; for the full suite no filter.

      Step 2 — `cargo build --release`.

      Step 3 — NO `DEPLOY.ps1` in this plan. Per cross-plan recommendation, deploy is consolidated:
      - Plan 02 will perform the single integrated deploy AFTER Plans 01+03 land (Plan 02's Task 4).
      - Plan 04 will perform the final deploy after the wrapper routing fix lands.

      Step 4 — Smoke-trace placeholder (deferred): record in SUMMARY the EXACT PowerShell command Plan 02 should run to verify the encoding fix post-deploy:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200
      # Look for the next [PSYCHE] resume entry; inspect body for:
      #  - Outer EVENT tag readable (<EVENT type="commune" ...>)
      #  - Inner <live-context>marker-live-xyz</live-context> readable (literal < and >, NOT &lt; / &gt;)
      #  - Inner <project-context>marker-proj-abc</project-context> readable
      #  - No mixed encoding state
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY (codex strength — preserve): "When `process_file_drop` receives a file_drop EVENT, the body is in state X" where X = encoded/decoded per the chosen Option from Task 2. Plan 04 Task 1 reads this directly.
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - Full test suite passes.
      - `cargo build --release` succeeds.
      - NO deploy in this plan; Plan 02 owns the consolidated deploy.
      - SUMMARY contains the post-deploy smoke command (PowerShell) and Plan 04 handoff note.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Defect C fix: identified and removed the asymmetric encoding site in the producer→spool→poll→wrapper→Psyche chain. New `event_body_unescape` inverse. Single encode at producer; one body decode per transport traversal at the chosen consumer boundary (Option A or B). EVENT-PART chunk-boundary tests added. Single source-of-truth contract doc block in `src/owl/poll.rs` (or `encoding.rs`). NO deploy yet (Plan 02 deploys after both 01+03).
    </what-built>
    <how-to-verify>
      1. Read the new contract doc block at the top of `src/owl/poll.rs` (or `src/owl/encoding.rs`). Confirm it describes encode/decode points, the amp-last invariant, AND the reframed "one body decode per transport traversal" invariant — unambiguously.
      2. Inspect the SUMMARY's chain trace table — confirm only ONE encode and AT MOST ONE decode per traversal in the full chain.
      3. Run `cargo test --release` locally (or trust the Task 4 output). Confirm encoding-chain + chunk-boundary tests pass.
      4. Optional: read the asymmetric-site fix diff — confirm the fix is at the SOURCE (not compensated downstream).
    </how-to-verify>
    <resume-signal>Type "approved" if the contract is clean, the asymmetric site is fixed at the source, and the chunk-boundary tests pass. Or describe remaining asymmetry / failing tests.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | User-supplied commune body → producer encode | Untrusted text containing arbitrary characters including `<`, `&`, `"`, `\n`, literal `</EVENT>`. |
  | Spool storage / TCP transport | Encoded body in transit between processes. |
  | Wrapper LLM-stdin write | Final boundary; body must be in the form the LLM expects per chosen Option. |
  | EVENT-PART chunk reassembly | Phase 27 chunker reassembles split bodies; boundary correctness is critical. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.03-T1 | Tampering | User-supplied body containing literal `</EVENT>` to escape outer envelope framing | mitigate | `event_body_escape` converts `<` to `&lt;` at producer time, making framing-injection impossible. The decode happens AFTER framing has been parsed (codex HIGH) — the body extracted from a parsed envelope cannot reframe upstream layers because they have already consumed the structural tokens. Tested via `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body`. |
  | T-25.3.03-T2 | Tampering | Decode order regression (amp-first introduces double-decode of `&amp;lt;` → `<` instead of `&lt;`) | mitigate | Amp-last invariant documented in `event_body_unescape` doc + enforced by unit test `event_body_unescape_inverts_event_body_escape_for_amp_first_content`. |
  | T-25.3.03-T3 | Tampering | Body content reframes upstream via global string unescape (codex HIGH) | mitigate | The contract explicitly forbids decoding the full envelope string globally. Decode invoked ONLY on the body-extracted substring (post-framing-parse). Contract doc states this; tests enforce. |
  | T-25.3.03-T4 | Tampering | Chunk-boundary corruption at EVENT-PART reassembly | mitigate | New chunk-boundary tests cover `</EVENT>`, `<br>`, `&amp;` straddling splits. Reassembly is byte-identical; decode is post-reassembly. |
  | T-25.3.03-D1 | Denial of service | Pathological body with millions of nested `<br>` exhausts memory during decode | accept | `String::replace` is O(n*m) but each pass scans linearly. EVENT-PART chunker (Phase 27) caps individual chunk sizes; full-body cap if any exists is documented in `<encoded_body_cap>` (flag for follow-up if no cap exists). |
  | T-25.3.03-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Chain trace table in SUMMARY documents every encode + decode site.
  - Asymmetric site fixed at the source.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 6 unit tests + 3 chunk-boundary tests + 1 end-to-end test pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - NO deploy from this plan.
  </verification>

  <success_criteria>
  - The original Defect C symptom (outer raw, inner encoded in same body) cannot reproduce after the fix.
  - All Self → wrapper → Psyche payload deliveries observe the documented contract.
  - No regression on existing EVENT framing (Phase 23 stamping, Phase 25 two-slice routing, Phase 27 EVENT-PART chunking, Phase 30 file-drop).
  - Plan 04 has an unambiguous answer to "is the body decoded when it reaches `process_file_drop`?" — direct consequence of the Option chosen in Task 2.
  - Plan 02 has an unambiguous answer to "what state does the body arrive in at LLM-stdin?" — same source.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md` when done. Include:
  - Chain trace table (every encode/decode site, file:line, input → output state).
  - Asymmetric site root-cause statement.
  - Chosen option (A or B) + rationale.
  - The fix applied (file:line + diff summary).
  - Contract doc block content (copy-paste verbatim — same string that lives in the codebase).
  - Test names + pass evidence (including 3 chunk-boundary tests).
  - Plan 02 handoff note: "Body state at LLM-stdin = <encoded|decoded>" per chosen Option.
  - Plan 04 handoff note: "Body state at process_file_drop = <encoded|decoded>" per chosen Option.
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>

  ---

  ### 25.3-04-PLAN.md

  ---
  phase: 25.3-project-context-envelope-persistence-encoding-defects
  plan: 04
  type: execute
  wave: 4
  depends_on:
    - 25.3-01
    - 25.3-03
    - 25.3-02
  files_modified:
    - src/live/wrapper/mod.rs
    - src/common/tracked.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-2 NOTE — codex review resolution map
  # Plan reorder per codex cross-plan recommendation: now wave 4 (was wave 3); 02 precedes 04.
  # Option 1 is LOCKED with stale-overwrite mitigation per codex HIGH. Option 2 (round-trip-via-LLM)
  # is explicitly DISFAVORED.
  #
  # This plan addresses the following codex HIGH/MEDIUM/LOW concerns from 25.3-REVIEWS.md (Plan 04 section):
  #   HIGH: "Option 1 ignores Plan 02 Option B's possible `name=` attr. If named project-context envelopes
  #     are adopted, routing by receiver last_project_name may write cross-project context to the wrong
  #     file." → RESOLVED by Plan 02 locked Option A (no-name shape). Routing always uses receiver-side
  #     resolver (Self's perch info.json `cwd` via Plan 01's helper). No name attr to consider.
  #   HIGH (locked decision #2): "Routing must use receiver-side resolver (info.json cwd per locked
  #     decision #2), NOT name= attr." → Task 2 routes via Plan 01's `resolve_self_project_name_via_info_cwd`
  #     helper. Same resolver as build_current_context_blocks — no third-site resolver.
  #   HIGH: "Direct route followed by stale LLM re-emit can overwrite the newly persisted inbound body.
  #     The plan acknowledges this but does not mitigate it." → Task 2 Step 2 adds STALE-OVERWRITE
  #     MITIGATION via monotonic content-merge: route_two_slice gets a new `inbound_baseline: Option<&str>`
  #     parameter (or sibling helper) that compares the new body against the existing on-disk content;
  #     if new body is a STRICT SUBSET (LLM emitted stale content that drops markers introduced by direct
  #     route), the write is REJECTED and a warning is logged. See task action for full design.
  #   HIGH: "Option 2 (round-trip-via-LLM) was the failure mode — DISFAVOR unless paired with enforcement.
  #     Plan should LOCK Option 1." → Option 1 is LOCKED in this plan. Task 1 checkpoint:decision retained
  #     but with a default + strong recommendation toward Option 1. Option 2 explicitly disfavored.
  #   MEDIUM: "Route failures must surface clear warning/status (not silently nonfatal)." → Task 2 Step 3:
  #     route failures emit `[FILE-DROP] route_two_slice failed for {self_id}: {err} (LLM handoff continues)`
  #     as a structured WARN-level log; also bump a counter in info.json `route_failure_count` (optional
  #     telemetry — defer if scope too tight). Non-fatal but VISIBLE.
  #   MEDIUM: "Extract small testable helper (route_inbound_commune_body) instead of testing through full
  #     wrapper process flow. Call out dependency injection." → Task 2 Step 1 extracts
  #     `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-
  #     testable seam. process_file_drop invokes the seam; tests target the seam directly without mocking
  #     resume_session_with_exit.
  #   LOW: "$OWL deliver heredoc not PowerShell-compatible." → Task 4 uses PowerShell here-string `@'...'@`
  #     piped into a temp file or PowerShell `--%` literal-args mode. Bash heredocs eliminated throughout.

  must_haves:
    truths:
      - "When an inbound commune file_drop EVENT containing a `<project-context>` envelope reaches the wrapper, the project-context body persists to `projects/<resolved_project>/<self_id>.md` on disk via wrapper-side `route_two_slice` call BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver (Self's perch info.json `cwd` → `project_name_from_cwd_path`). NOT a `name=` attr (Option A locked, no such attr exists)."
      - "STALE-OVERWRITE MITIGATION: the next echo_commune fire's LLM output CANNOT erase the direct-routed content. Monotonic merge guard: if the LLM's emitted `<project-context>` body is a STRICT SUBSET of the on-disk content (key markers missing), the merge writer warns and rejects the write OR keeps the larger version per defined rule (see Task 2)."
      - "The wrapper's `process_file_drop` is the gatekeeper for inbound routing; no other inbound envelope route exists."
      - "Route failures emit a STRUCTURED WARN log (`[FILE-DROP] route_two_slice failed for {self_id}: ...`); non-fatal but VISIBLE (codex MEDIUM)."
      - "End-to-end test demonstrates: inbound payload with marker text → marker appears in `projects/<name>/<self_id>.md` within SECONDS (Option 1 timing — synchronous, before LLM handoff)."
      - "Implementation is unit-testable via the extracted `route_inbound_commune_body(self_id, body, source)` helper (codex MEDIUM); tests do NOT need to mock `resume_session_with_exit`."
    artifacts:
      - path: "src/live/wrapper/mod.rs"
        provides: "Updated process_file_drop calls new `route_inbound_commune_body` helper before LLM handoff (Option 1 LOCKED). Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/common/tracked.rs"
        provides: "New `route_two_slice_with_baseline` variant (or new `route_two_slice_monotonic` sibling) that implements stale-overwrite mitigation. Or a small wrapper `merge_project_body_monotonic`."
        contains: "monotonic"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + stale-overwrite mitigation tests."
        contains: "route_inbound_commune_body"
    key_links:
      - from: "src/live/wrapper/mod.rs::process_file_drop"
        to: "src/live/wrapper/mod.rs::route_inbound_commune_body (NEW seam)"
        via: "synchronous direct-route call BEFORE LLM handoff"
        pattern: "route_inbound_commune_body"
      - from: "src/live/wrapper/mod.rs::route_inbound_commune_body"
        to: "src/owl/echo_commune.rs::resolve_self_project_name_via_info_cwd (Plan 01)"
        via: "receiver-side project resolver — same source as build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd|project_name_from_cwd_path"
      - from: "src/common/tracked.rs::route_two_slice"
        to: "stale-overwrite mitigation (monotonic merge guard)"
        via: "comparison of LLM-emit body against current on-disk content; subset → reject + warn"
        pattern: "monotonic|merge_project_body"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with codex HIGH mitigations locked in. The wrapper's `process_file_drop` (src/live/wrapper/mod.rs:1231) currently feeds inbound commune envelopes to the Psyche LLM via `resume_session_with_exit` and discards the response — never calls `route_two_slice` on the inbound envelope. Inbound `<project-context>` envelopes are silently lost when the LLM round-trip drops them (the failure mode that triggered Phase 25.3).

  Per LOCKED DECISIONS + codex HIGH:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies via `route_two_slice` BEFORE LLM handoff. Option 2 (round-trip-via-LLM) was the original failure mode and is explicitly DISFAVORED — codex HIGH. The checkpoint:decision in Task 1 records the choice but defaults to Option 1.

  - **Receiver-side resolver (locked decision #2).** Routing uses Plan 01's `resolve_self_project_name_via_info_cwd` (info.json `cwd` field) for project resolution — same source as `build_current_context_blocks`. NO `name=` attr (Option A locked in Plan 02).

  - **Stale-overwrite mitigation (codex HIGH).** After direct-route persists the inbound body, the next echo_commune fire's LLM output MUST NOT erase the routed content. Solution: monotonic merge guard — `route_two_slice` (or a sibling) compares new content against existing on-disk content; if the new body is a STRICT SUBSET (drops markers), the write is rejected and a warning is logged. The exact merge policy is defined in Task 2.

  - **Testable seam (codex MEDIUM).** Extract `fn route_inbound_commune_body(self_id, body, source) -> Result<RouteOutcome, String>` as the unit-testable helper. Tests target the seam directly — do NOT mock `resume_session_with_exit` or run a full wrapper process flow.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in <STATE> form per Plan 03 SUMMARY. Task 2 reads that handoff note.

  Output:
  - User decision recorded at Task 1 checkpoint (default Option 1 with strong recommendation).
  - `route_inbound_commune_body` helper extracted as testable seam.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - Stale-overwrite mitigation: monotonic merge guard in `route_two_slice` (or sibling).
  - End-to-end test demonstrating synchronous inbound persistence.
  - Binary rebuild + DEPLOY (final patch bump for Phase 25.3).
  - Operator-verify checkpoint.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @CLAUDE.md
  @.planning/ROADMAP.md
  @.planning/debug/todlando-project-context-not-persisted.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-REVIEWS.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-01-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-02-PLAN.md
  @.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-PLAN.md
  @src/live/wrapper/mod.rs
  @src/common/tracked.rs
  @psyche.md

  <interfaces>
  <!-- Key sites and helpers for Option 1 implementation. -->

  From src/live/wrapper/mod.rs:1231 (process_file_drop):
  - Reads file_drop file content from disk (line 1247: `std::fs::read_to_string(path)`).
  - Composes a NEW EVENT envelope via `compose_commune_payload` (line 1279) for "commune" kind.
  - For commune: passes envelope to `resume_session_with_exit(&envelope)` (line 1318).
  - For signoff: passes envelope to `final_session(&envelope)` (line 1315).
  - Returns FileDropOutcome; deletes file on exit 0.

  From src/common/tracked.rs (Plan 25 outcome):
  - `pub fn route_two_slice(self_id: &str, body: &str, source: &str, short: &str) -> Result<(), ...>`
  - `pub fn parse_two_slice(body: &str) -> ParseResult` — parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).

  From src/owl/echo_commune.rs (Plan 01 outcome):
  - `fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01. Reads Self's perch info.json `cwd` field and resolves via `project_name_from_cwd_path`.
  - This plan REUSES that helper at the wrapper site (the third resolver consumer — codex MEDIUM noted duplication concern is now a single shared helper).

  From Plan 03 outcome (read 25.3-03-SUMMARY.md at execute time):
  - Body state at wrapper file_drop boundary = <encoded|decoded>. If Plan 03 chose Option A (decode at LLM-stdin), the wrapper file_drop site sees DECODED body (because filesystem read yields raw user prose with literal tags). If Plan 03 chose Option B (decode at poll), same outcome: decoded body. Either way: at process_file_drop, body has LITERAL `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`.

  From psyche.md (Plan 02 outcome):
  - `<absorption>` section teaches the LLM to detect, merge, and re-emit inbound `<project-context>` envelopes.
  - The LLM is taught "SPT routes" — meaning this plan IS that SPT routing.
  </interfaces>

  </context>

  <tasks>

  <task type="auto">
    <name>Task 0: Read Plan 03 SUMMARY to confirm body-state at process_file_drop boundary</name>
    <files>(read-only)</files>
    <action>
      Read `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md`.

      Extract the Plan-04-handoff line: "Body state at process_file_drop = <encoded|decoded>."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      If the body is ENCODED at process_file_drop, Task 2 inserts a `crate::owl::poll::event_body_unescape` call before invoking `route_inbound_commune_body`.

      If the body is DECODED (expected case — filesystem read of user-written commune file), no decode pass needed.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read.
      - Body state at process_file_drop documented in this plan's SUMMARY.
      - Task 2 has the decode-or-not answer.
    </done>
  </task>

  <task type="checkpoint:decision" gate="blocking">
    <decision>Wrapper inbound routing strategy — Option 1 (LOCKED default) vs Option 2 (DISFAVORED)</decision>
    <context>
      Per locked decisions + codex HIGH: Option 1 (direct wrapper route before LLM handoff) is LOCKED as the default and strongly recommended. Option 2 (round-trip-via-LLM with re-emit hardening) is DISFAVORED — it relies on prompt compliance, which is the failure mode that triggered this phase. This checkpoint exists for record-keeping; the strong recommendation is Option 1.

      Option 1 (LOCKED default) — Wrapper direct-routes `<project-context>` before LLM handoff.
      - Inside `process_file_drop`'s "commune" arm, after reading the body but BEFORE composing the EVENT envelope, call the new `route_inbound_commune_body(self_id, body, "commune-inbound")` seam.
      - Synchronous persistence; inbound envelope hits disk immediately.
      - Survives a Psyche LLM that drops the envelope.
      - STALE-OVERWRITE MITIGATION (codex HIGH): the next echo_commune fire's LLM output cannot erase the routed content (monotonic merge guard).
      - Failure mode of original Defect A is eliminated.

      Option 2 (DISFAVORED) — Do not modify process_file_drop; rely on Plan 02's absorption + re-emit teaching only.
      - Smaller code surface but no defense against LLM dropping the envelope.
      - Only acceptable if the user actively chooses this path over codex's HIGH recommendation.
    </context>
    <options>
      <option id="option-1">
        <name>Wrapper direct-route via route_inbound_commune_body before LLM handoff (LOCKED DEFAULT)</name>
        <pros>Synchronous; survives LLM drops; deterministic; observable on every inbound commune. Receiver-side resolver via Plan 01 (no third resolver site — same shared helper). Stale-overwrite mitigation via monotonic merge guard.</pros>
        <cons>Marginally more code than Option 2; requires the monotonic merge guard.</cons>
      </option>
      <option id="option-2">
        <name>Round-trip-via-LLM only (DISFAVORED per codex HIGH)</name>
        <pros>Smaller code surface.</pros>
        <cons>Defends only with prompt engineering; LLM drops still cause data loss. Codex HIGH explicitly disfavors. Do not select unless user has a specific reason.</cons>
      </option>
    </options>
    <resume-signal>Select: option-1 (default; press enter to accept) or option-2 (must explicitly type with rationale).</resume-signal>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Extract route_inbound_commune_body seam + wire process_file_drop + add stale-overwrite mitigation</name>
    <files>src/live/wrapper/mod.rs, src/common/tracked.rs</files>
    <behavior>
      - SKIP this task if Option 2 chosen at Task 1; proceed to Task 3.
      - `route_inbound_commune_body(self_id, body, source)` (NEW helper in `src/live/wrapper/mod.rs` or a sibling module) is unit-testable WITHOUT mocking `resume_session_with_exit` or running a full wrapper process.
      - When invoked with a body containing `<project-context>marker-abc</project-context>`, `projects/<resolved_project>/<self_id>.md` contains `marker-abc` on disk after return (assuming Plan 01's resolver returns a valid project for self_id).
      - When invoked with a body containing NO envelope tags, parse_two_slice puts the entire body in the live slot (existing fallback) → `live_context.md` is updated; no project write.
      - When invoked and resolver returns None (no project resolved for self_id), the project slice is dropped silently per existing route_two_slice D-09; live slice still routes.
      - **Stale-overwrite mitigation:** when a SUBSEQUENT echo_commune fire's LLM output emits a `<project-context>` body that is a STRICT SUBSET of the current on-disk content (a defined "subset" rule — see action), the merge writer REJECTS the write and emits a structured WARN log. This protects the direct-routed inbound marker from being erased by a stale LLM emit.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Route failures are structured WARN logs and non-fatal — LLM handoff continues regardless.
      - "signoff" arm is unchanged.
    </behavior>
    <action>
      Step 1 — Extract the testable seam in `src/live/wrapper/mod.rs`. Add a new function near `process_file_drop`:

      ```rust
      /// 25.3-04 Defect A — extracted testable seam for inbound commune routing.
      ///
      /// Parses `body` via `parse_two_slice`, resolves Self's project via the
      /// receiver-side resolver from Plan 01 (info.json `cwd` field), and routes
      /// each slice to disk. NO LLM handoff in this function — that is the caller's
      /// responsibility.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM):
      /// callers pass `self_id` + raw body; the function consumes Plan 01's resolver
      /// and Plan 25's route_two_slice (with monotonic merge guard for project slice).
      ///
      /// Returns RouteOutcome on success (records which slices were routed);
      /// Err(String) on hard failures (resolver unreachable, IO error).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,
      ) -> Result<RouteOutcome, String> {
          let short: String = body
              .lines()
              .map(|l| l.trim())
              .find(|l| !l.is_empty())
              .map(|l| l.chars().take(80).collect())
              .unwrap_or_else(|| "commune-inbound".to_string());

          // Plan 03 contract reference: body is in DECODED form at this site
          // (filesystem read of user-written commune file).
          // If Plan 03 SUMMARY's handoff note says ENCODED, decode here:
          // let body = crate::owl::poll::event_body_unescape(body);
          // (Task 0 already confirmed the correct branch.)

          // route_two_slice_monotonic is the new sibling that applies the
          // stale-overwrite guard for the project slice. For inbound source =
          // "commune-inbound", we want UNCONDITIONAL write of the inbound delta
          // (this IS the source of truth at this moment), so we use the standard
          // route_two_slice. The monotonic guard fires on the SUBSEQUENT
          // echo_commune emit only — see Step 2.
          crate::common::tracked::route_two_slice(self_id, body, source, &short)
              .map(|_| RouteOutcome { live_routed: true, project_routed: true })
              .map_err(|e| e.to_string())
      }

      pub(crate) struct RouteOutcome {
          pub live_routed: bool,
          pub project_routed: bool,
      }
      ```

      Step 2 — Add the STALE-OVERWRITE MITIGATION in `src/common/tracked.rs`. The guard fires on the OUTBOUND echo_commune emit path (NOT on inbound — inbound is the source of truth). Implementation: add a new function `route_two_slice_monotonic` OR extend `route_two_slice` with an internal subset-check for the project slice.

      Recommended approach: add `fn route_two_slice_monotonic(self_id, body, source, short)` as a SIBLING that wraps `route_two_slice` with this additional guard ONLY for the project slice:

      ```rust
      /// 25.3-04 stale-overwrite mitigation. Identical to `route_two_slice` except:
      /// before writing the project slice, READ the current on-disk project file.
      /// If the new body is a STRICT SUBSET of the existing content per the
      /// `is_strict_subset` predicate (see below), REJECT the write and emit a
      /// structured WARN log: `[ROUTE-GUARD] rejected stale project body for {self_id}
      /// (new body is strict subset of on-disk; preserving existing)`.
      ///
      /// Subset rule (defined per 25.3-04 Task 2 codex HIGH mitigation):
      ///   `is_strict_subset(new, existing)` = true iff EVERY non-blank line of
      ///   `existing` is present in `new` is FALSE for at least one line AND every
      ///   non-blank line of `new` appears in `existing`. Translation: `new` is
      ///   missing markers that `existing` has, and `new` does not introduce any
      ///   new content. This is the "LLM dropped content" signature.
      ///
      /// Live slice is ALWAYS unconditionally written (no monotonic guard on
      /// live_context.md — it is intentionally ephemeral).
      pub fn route_two_slice_monotonic(
          self_id: &str,
          body: &str,
          source: &str,
          short: &str,
      ) -> Result<(), String> { /* impl */ }
      ```

      Wire the OUTBOUND echo_commune fire (`src/owl/echo_commune.rs::run_echo_commune` at line 854) to use `route_two_slice_monotonic` instead of `route_two_slice`. INBOUND wrapper-direct route (Task 2 Step 1) keeps the unconditional `route_two_slice`.

      Step 3 — Update `process_file_drop` in `src/live/wrapper/mod.rs`. Inside the "commune" arm, BEFORE composing the EVENT envelope (currently at line 1278):

      ```rust
      // 25.3-04 Option 1 (LOCKED): wrapper-direct route inbound commune body
      // BEFORE LLM handoff. The LLM still receives the envelope below; the route
      // is a synchronous SIDE EFFECT that hardens against LLM drops. Stale-
      // overwrite is mitigated by the monotonic guard on the OUTBOUND
      // echo_commune emit (route_two_slice_monotonic).
      if kind == "commune" {
          match route_inbound_commune_body(&self.self_id, &body, "commune-inbound") {
              Ok(outcome) => self.log(&format!(
                  "[FILE-DROP] route_two_slice ok for {} (commune-inbound; live={}, project={})",
                  self.self_id, outcome.live_routed, outcome.project_routed
              )),
              Err(e) => self.log(&format!(
                  "[FILE-DROP] route_two_slice failed for {}: {} (LLM handoff continues)",
                  self.self_id, e
              )),
          }
      }
      ```

      Codex MEDIUM (visible failures): the WARN log is structured with a stable prefix `[FILE-DROP]` so operators can `Select-String -Pattern '\[FILE-DROP\] route_two_slice failed'` to find failures.

      Step 4 — Unit tests targeting the seam (NO wrapper mocking — codex MEDIUM):
      - `route_inbound_commune_body_writes_project_slice_with_marker` — set up fake tracked tree + info.json with cwd → temp .git repo; pass body with `<project-context>marker-abc</project-context>`; assert `projects/<name>/<self_id>.md` contains "marker-abc". `#[serial_test::serial]` (env-mutating).
      - `route_inbound_commune_body_writes_live_slice_only_when_no_project_envelope` — body with only `<live-context>...</live-context>`; assert live_context.md updated; no project file. `#[serial]`.
      - `route_inbound_commune_body_no_envelope_tags_fallback` — body with no tags; parse_two_slice fallback routes to live slot. `#[serial]`.
      - `route_inbound_commune_body_returns_err_when_resolver_fails` — info.json missing AND process cwd resolves to psyche_dir; assert resolver fails gracefully (the existing route_two_slice D-09 silent-drop applies). `#[serial]`.

      Stale-overwrite mitigation tests (target `route_two_slice_monotonic` directly):
      - `route_two_slice_monotonic_rejects_strict_subset_project_body` — write `marker-a\nmarker-b\nmarker-c` to project file; then call with body `<project-context>marker-a\nmarker-b</project-context>`; assert file content UNCHANGED + WARN log emitted. `#[serial]`.
      - `route_two_slice_monotonic_accepts_strict_superset` — same setup; call with body adding `marker-d`; assert file content updated to include marker-d. `#[serial]`.
      - `route_two_slice_monotonic_accepts_overlapping_with_new_content` — new body shares some markers but adds a new one; assert write goes through. `#[serial]`.
      - `route_two_slice_monotonic_always_writes_live_slice` — verify the monotonic guard applies ONLY to project slice; live_context.md is unconditionally updated. `#[serial]`.

      End-to-end ordering test:
      - `process_file_drop_commune_routes_before_llm_handoff_ordering` — verify route_inbound_commune_body is called BEFORE resume_session_with_exit in the "commune" arm. This can be a structural test (read process_file_drop source order) OR a small mock-injection test if existing test scaffolds permit. If structural, document as such.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_monotonic 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, unit-testable without LLM mocking.
      - `route_two_slice_monotonic` exists in `src/common/tracked.rs` with `is_strict_subset` predicate.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
      - Outbound echo_commune fire uses `route_two_slice_monotonic` (stale-overwrite mitigation).
      - Inbound wrapper-direct uses standard `route_two_slice` (unconditional, source of truth at that moment).
      - Route failures are structured WARN logs (operator-visible).
      - 4 seam tests + 4 monotonic-guard tests + 1 ordering test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3 (Option 2 path — DISFAVORED): Harden psyche.md re-emit guarantee only</name>
    <files>psyche.md</files>
    <action>
      SKIP this task if Option 1 was chosen at Task 1 (the LOCKED default). Proceed to Task 4.

      This task only executes if the user explicitly overrode the codex HIGH recommendation and selected Option 2. In that case:

      Add to psyche.md `<absorption>` section (built in Plan 02), a new rule:

      "**Re-emit guarantee (Option 2 — 25.3-04):** Every fire that sees any inbound `<project-context>` envelope (in this fire's prompt OR in any inbound payload processed since the previous fire) MUST include a `<project-context>` envelope in its output context-save, even if the body is unchanged. Omitting `<project-context>` when one has been observed is forbidden — the receiver-side writer treats absence as 'no project context this cycle' and writes nothing; a guaranteed re-emit ensures the on-disk file converges to the LLM's in-memory state."

      Add a Rust-side regression detector in `src/owl/echo_commune.rs::run_echo_commune` (after haiku stdout capture, before route_two_slice at line 854):
      - If `prompt.contains("CURRENT_PROJECT_CONTEXT")` AND NOT `output.contains("<project-context>")`, log:
        ```
        eprintln!("[ECHO-WARN] haiku prompt had CURRENT_PROJECT_CONTEXT but output omitted <project-context> for {} (25.3-04 Option 2 re-emit guarantee violated)", self_id);
        ```

      This is observability only — Option 2 explicitly does NOT enforce.
    </action>
    <verify>
      <automated>(Select-String -Path psyche.md -Pattern 'Re-emit guarantee' -SimpleMatch | Measure-Object).Count; cargo build --release 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - (Option 2 only) psyche.md has "Re-emit guarantee" rule.
      - (Option 2 only) Rust detector emits `[ECHO-WARN]` on prompt-output asymmetry.
      - `cargo build --release` succeeds.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: Rebuild + deploy + end-to-end verification (Option 1 path)</name>
    <files>Cargo.lock, plugin/spt/.claude-plugin/plugin.json</files>
    <action>
      Step 1 — `cargo test --release` (full suite; confirm no regressions).

      Step 2 — `cargo build --release`.

      Step 3 — Final deploy for Phase 25.3:
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      STOP before `/reload-plugins` (per CLAUDE.md). User runs it.

      Step 4 — After user reload, send a deliberate inbound commune to todlando using PowerShell (NOT Bash heredoc — codex LOW):

      Approach A — temp file:
      ```powershell
      $body = @'
      <project-context>marker-25.3-04-Z timestamp-test</project-context>
      '@
      $tmp = New-TemporaryFile
      Set-Content -NoNewline -Path $tmp -Value $body
      & $env:OWL deliver todlando --file $tmp
      Remove-Item $tmp
      ```

      Approach B — PowerShell literal-args mode `--%`:
      ```powershell
      $env:OWL --% deliver todlando "<project-context>marker-25.3-04-Z timestamp-test</project-context>"
      ```

      Use whichever approach the `$OWL deliver` CLI supports — inspect `--help` first.

      Step 5 — Verification (Option 1 timing):
      Within SECONDS (before the next echo_commune fire — Option 1 is synchronous), the marker text appears in `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md`:
      ```powershell
      Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
      ```
      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok for todlando \(commune-inbound'
      ```

      Step 6 — Stale-overwrite mitigation smoke: wait for the next echo_commune fire and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that is a STRICT SUBSET (missing the marker), confirm the `[ROUTE-GUARD] rejected stale project body` log line appears in todlando.log.
      - If the haiku emitted a SUPERSET (preserving the marker + adding more), confirm the file content is the new superset.

      Step 7 — Confirm the on-disk file content's marker text round-tripped intact (no encoding artifacts — Plan 03's chain verification).
    </action>
    <verify>
      <automated>cargo test --release 2>&1 | Select-Object -Last 5; Test-Path "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"</automated>
    </verify>
    <done>
      - Full test suite passes.
      - DEPLOY succeeds; plugin version bumped (final patch bump for Phase 25.3).
      - Marker text appears in `projects/claude_skill_owl/todlando.md` within SECONDS of inbound commune.
      - `[FILE-DROP] route_two_slice ok` log line confirmed for the inbound.
      - Stale-overwrite mitigation verified: either marker preserved across next echo_commune fire OR `[ROUTE-GUARD] rejected stale project body` log line confirmed.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 5: Phase-level invariant test (cross-plan; codex cross-plan recommendation #6)</name>
    <files>tests/native_owl.rs</files>
    <action>
      Add one phase-level invariant test per codex cross-plan recommendation #6:

      `phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`

      Scenario:
      - Set up a temp SPT_HOME with a fake perch info.json for `test-agent` carrying `cwd = <path to a temp .git repo named "test_project">`.
      - Invoke `route_inbound_commune_body("test-agent", body, "commune-inbound")` with body:
        ```
        <live-context>live-marker-aaa</live-context>
        <project-context>project-marker-bbb</project-context>
        ```
      - Assert ALL THREE invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO HTML-encoded tags in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;`, no `&amp;`). (Defect C invariant.)
        (c) Marker text in expected file — `projects/test_project/test-agent.md` contains `project-marker-bbb`; `agents/test-agent/live_context.md` contains `live-marker-aaa`. (Defects D + A invariant.)
      - `#[serial_test::serial]` (env-mutating).

      This is the single integration test that validates the entire phase 25.3 fix as a unit. If this test passes, the four defects are jointly resolved.
    </action>
    <verify>
      <automated>cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly 2>&1 | Select-Object -Last 15</automated>
    </verify>
    <done>
      - Phase-level invariant test exists and passes.
      - Test asserts no `"tracked"` artifact, no HTML-encoded tags, correct marker placement.
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; stale-overwrite mitigation via `route_two_slice_monotonic`; structured failure logs.
      - Cross-plan: phase-level invariant test (Task 5) jointly validates all four defects.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk, with stale-overwrite protection against subsequent LLM drops.
    </what-built>
    <how-to-verify>
      1. Send a `<project-context>` envelope with a fresh marker to todlando via PowerShell:
         ```powershell
         $body = @'
         <project-context>final-verify-marker-25.3</project-context>
         '@
         # via temp file or $env:OWL --% deliver — see Task 4 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_two_slice ok'
         ```
      4. Send a SECOND `<project-context>` with a different marker and confirm both markers coexist OR the second's content correctly supersedes (depending on whether the second is a superset or causes the monotonic guard to fire).
      5. Confirm `live_context.md` continues to update normally (no regression on live slice).
      6. Confirm no `projects/tracked/` directory exists (Defect B2 invariant).
      7. Run phase-level invariant test: `cargo test --release phase_25_3_invariant_inbound_two_slice_envelope_persists_correctly`.
    </how-to-verify>
    <resume-signal>Type "approved" if all four defects are jointly resolved per the verification steps, or describe the failure mode.</resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Inbound file_drop file contents → process_file_drop | User-controlled prose written by another agent (or this agent's Self listener); could contain malformed or malicious envelope structures. |
  | route_two_slice writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md`. |
  | Monotonic merge guard | Reads existing on-disk content; compares against new body; rejects on strict-subset. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-25.3.04-T1 | Tampering | Malformed `<project-context>` body containing path traversal (e.g. `..\..\..\Windows\System32`) | mitigate | Option A no-name shape (Plan 02 lock) — there is NO attribute in the envelope that can carry a path. Destination path is resolved by SPT via Plan 01's info.json `cwd` resolver, never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice` joins under `tracked_root()/projects/<resolved_name>/` with no path-traversal escape. |
  | T-25.3.04-T2 | Tampering | Inbound envelope overwrites legitimate on-disk content (race with LLM round-trip) | mitigate | STALE-OVERWRITE MITIGATION (codex HIGH): `route_two_slice_monotonic` applies the `is_strict_subset` guard on the OUTBOUND echo_commune emit path. If the LLM emits a body that drops markers present on disk, the write is REJECTED and a WARN log surfaces. Inbound direct-route remains unconditional (it IS the new source of truth). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to defeat the subset guard (e.g. paraphrases markers so they look new) | accept | The subset guard is a heuristic, not a cryptographic check. An attacker controlling inbound content can already inject arbitrary prose into the project file — the route_two_slice surface trusts inbound by design. The guard protects against ACCIDENTAL LLM stale emit, not adversarial input. |
  | T-25.3.04-D1 | Denial of service | Inbound envelope storm exhausts disk via repeated writes | accept | Existing route_two_slice + Phase 24 worktree commit cadence already absorbs N-per-second writes. Monotonic guard adds one extra disk read per write; negligible. |
  | T-25.3.04-R1 | Repudiation | Route failure goes unnoticed | mitigate | Structured WARN logs with stable prefix `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Codex MEDIUM resolution. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. |
  </threat_model>

  <verification>
  - Task 1 checkpoint records user decision (default Option 1).
  - Task 2 implements Option 1 with stale-overwrite mitigation; Task 3 skipped (Option 2 disfavored).
  - Task 4 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 5 phase-level invariant test asserts joint resolution of all four defects.
  - Task 6 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite).
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - User decision recorded (Option 1 default).
  - `route_inbound_commune_body` seam exists; unit-testable without LLM mocking.
  - `route_two_slice_monotonic` exists with `is_strict_subset` guard.
  - process_file_drop "commune" arm routes BEFORE LLM handoff.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS (Option 1 synchronous timing).
  - Stale-overwrite mitigation verified: next echo_commune fire CANNOT erase direct-routed inbound content.
  - Phase-level invariant test passes (joint resolution of all 4 defects).
  - All PowerShell — no Bash heredocs (codex LOW).
  - Operator-verify approved.
  - All four defects (A, B2, C, D) from the debug doc closed.
  </success_criteria>

  <output>
  Create `.planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-04-SUMMARY.md` when done. Include:
  - Chosen option (1 LOCKED default OR 2 with explicit user override rationale).
  - Plan 03 body-state-at-process_file_drop confirmation (from Task 0).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call).
  - Diff summary for `src/common/tracked.rs` (`route_two_slice_monotonic` + `is_strict_subset`).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content).
  - Stale-overwrite mitigation smoke verification.
  - Phase-level invariant test pass evidence.
  - Codex review resolution table: each HIGH/MEDIUM/LOW addressed → task → verification.
  - Phase 25.3 roll-up: link to all 4 SUMMARYs (01, 03, 02, 04 — in execution order) and a one-paragraph statement of the net architectural improvement (inbound `<project-context>` envelopes now persist synchronously with stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>

  ---


  ## Review Instructions

  Compare cycle-2 plans against your cycle-1 concerns. For each prior HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then provide a fresh per-plan review with severities (HIGH/MEDIUM/LOW). Mark severity bullets exactly as: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **Overall risk after revision**: LOW/MEDIUM/HIGH + one-line justification. Mention if convergence reached (no remaining HIGHs).


