# threads

id: 019e528b-ecac-7973-b670-09f0c0f3661f
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-56-02-019e528b-ecac-7973-b670-09f0c0f3661f.jsonl

title:
  # Cross-AI Plan Review Request (Cycle 3 — Final convergence check)

  These plans went through TWO prior cycles. Cycle 1 had 9 HIGHs (all resolved). Cycle 2 introduced 3 new HIGHs (claimed resolved in cycle 3). Verify convergence.

  ## Phase 25.3 — Roadmap
  ### 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 Reviews (Cycles 1 + 2)

  ---
  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**.

  ---

  # Cycle 2 Re-Review (post-replan)

  **Reviewed at**: 2026-05-22T18:10:00-07:00

  ## Prior HIGH Resolution

  | Prior HIGH | Status | Evidence |
  |---|---|---|
  | Plan 01: `resolve_self_project_stamp` could still fall back to cwd and emit `project="tracked"` | **RESOLVED** | Plan 01 now routes both stamp and context-block resolution through `info.json.cwd` plus a final cwd fallback guard. |
  | Plan 02: Option B exceeded file scope | **RESOLVED** | Plan 02 locks Option A no-name `<project-context>` and removes parser/writer changes from scope. |
  | Plan 02: “Psyche writes file” vs “Rust routes envelope” ambiguity | **RESOLVED** | Plan 02 explicitly standardizes on “SPT routes; you emit.” |
  | Plan 02: `<project-context name="...">` vs no-name inconsistency | **RESOLVED** | Option A no-name envelope is locked throughout. |
  | Plan 03: body decoding before EVENT framing parse would be unsafe | **RESOLVED** | Plan 03 now requires body decode only after framing/body extraction. |
  | Plan 03: missing dependency on envelope-shape decision | **RESOLVED** | Option A is locked, and Plan 03 now runs before Plan 02 with that assumption. |
  | Plan 04: named envelope routing ambiguity | **RESOLVED** | No-name Option A removes `name=` routing entirely. |
  | Plan 04: stale LLM re-emit could overwrite direct-routed content | **PARTIAL** | Monotonic guard is added, but the proposed strict-subset heuristic may reject legitimate summarization or still miss stale rewrites. |
  | Plan 04: Option 2 relies on prompt compliance | **RESOLVED** | Option 1 direct wrapper routing is locked/default; Option 2 is explicitly disfavored. |

  ## Fresh Review

  ### 25.3-01

  - **MEDIUM:** Plan 01 says `"tracked"` rejection is path-context-sensitive at all three layers, but the legacy perch field branch rejects `p == "tracked"` globally. That contradicts the stated invariant, even if today that field is usually unset.
  - **MEDIUM:** `resolve_self_project_name_via_info_cwd` appears introduced as a private helper in `src/owl/echo_commune.rs`, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it `pub(crate)` in a shared module or avoid depending on it outside `echo_commune.rs`.
  - **LOW:** Adding `serial_test` is pragmatic, but it expands the dependency surface for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not blocking.

  ### 25.3-02

  - **MEDIUM:** The static prompt test checks for absence of `<project-context name=` only in `psyche.md`, while the plan’s invariant says no name examples anywhere in psyche plus skill files. Add the same guard over `commune.md` and `signoff/SKILL.md`.
  - **MEDIUM:** The plan still depends on a human/LLM smoke cycle to prove artifact materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking verification, not just a deployment smoke.
  - **LOW:** Task numbering skips from Task 0 to Task 2. Not functionally harmful, but easy to confuse during execution.

  ### 25.3-03

  - **HIGH:** Plan 03 says the LLM stdin should have no `&amp;` entities in the end-to-end test. That is too strong if user content legitimately contains a literal encoded string like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not “no entities anywhere.”
  - **MEDIUM:** `event_body_unescape` uses `<br>` -> newline first, which means a user’s literal `<br>` text will not round-trip through `event_body_escape` because `<br>` is escaped as `&lt;br&gt;`, not `<br>`. The test named “literal_br” needs to distinguish transport newline markers from user literal `<br>`.
  - **MEDIUM:** The plan still includes a blocking decision between Option A and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If convergence is desired, lock Option A unless there is a real reason to keep the checkpoint.

  ### 25.3-04

  - **HIGH:** `route_inbound_commune_body` claims `project_routed: true` whenever `route_two_slice` returns `Ok`, but existing semantics may silently drop the project slice when no project resolves. That makes logs and tests falsely report project persistence. The outcome must reflect actual writes.
  - **HIGH:** The monotonic guard is underspecified for real context evolution. “Strict subset by nonblank lines” can reject valid summarization/compaction and can fail to catch stale rewrites that paraphrase or reorder. For a safety guard, prefer marker/version metadata, append-only merge, or explicit preserve-on-conflict behavior with tests for expected LLM summarization.
  - **MEDIUM:** Plan 04’s files_modified omits `src/owl/echo_commune.rs`, but Task 2 wires outbound echo_commune to `route_two_slice_monotonic` there.
  - **MEDIUM:** The helper placement/resolver dependency is muddy: Plan 04 says route via Plan 01’s resolver in `echo_commune.rs`, but wrapper should not depend on an owl module-private helper. Move the resolver to `common/owlery.rs` or `common/tracked.rs`.
  - **LOW:** The structural source-order test for “routes before LLM handoff” is brittle. A small dependency-injected function boundary would give a stronger behavioral test.

  **Overall risk after revision**: **HIGH**. Most Cycle 1 contract issues are resolved, but convergence is not reached because Plan 03’s entity invariant and Plan 04’s false route outcome plus weak stale-overwrite guard introduce remaining HIGH risks.

  ## Cycle-3 Plans

  ### 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/common/owlery.rs
    - src/owl/echo_commune.rs
    - Cargo.toml

  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs (cycle-2 HIGHs already resolved)
  #
  # Cycle-2 codex MEDIUMs addressed in this update:
  #   MEDIUM: "Plan 01 says 'tracked' rejection is path-context-sensitive at all three layers, but the legacy
  #     perch field branch rejects p == 'tracked' globally. That contradicts the stated invariant, even if
  #     today that field is usually unset." → Task 1 Step 3: legacy-perch branch now context-sensitizes the
  #     "tracked" rejection. It only rejects p == "tracked" when the SAME info.json's `cwd` path resolves to
  #     psyche_dir (mirrors layer-1 rule). A user with a real `tracked` project + a legacy perch field
  #     literally written "tracked" keeps that value. New test
  #     `resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` guards this.
  #   MEDIUM: "resolve_self_project_name_via_info_cwd appears introduced as a private helper in
  #     src/owl/echo_commune.rs, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it
  #     pub(crate) in a shared module or avoid depending on it outside echo_commune.rs." → Task 1 Step 3:
  #     `resolve_self_project_name_via_info_cwd` is now defined in `src/common/owlery.rs` as `pub(crate)`
  #     (NOT in echo_commune.rs). `resolve_self_project_stamp` and `build_current_context_blocks`
  #     consume it via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Plan 04's wrapper
  #     route uses the SAME path. Single source of truth.
  #   LOW (accepted, not addressed): "Adding serial_test is pragmatic, but it expands the dependency surface
  #     for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not
  #     blocking." → Kept `serial_test` as a `[dev-dependencies]` entry. Trade-off documented: serial_test is
  #     dev-only, widely-used, and the local-mutex alternative is non-blocking. If reviewer escalates, the
  #     local Mutex<()> swap is a single-commit change.
  #
  # Cycle-2 HIGHs (already resolved in cycle 2 — do NOT regress):
  #   - info.json-cwd-first resolver chain (locked decision #2).
  #   - belt-and-braces "tracked" filter at all three resolver layers (path-context-sensitive).
  #   - `(name=<X>)` debug-visible token in CURRENT_PROJECT_CONTEXT header (smoke verification).
  #   - `find_git_root_basename` made `pub(crate)`.
  #   - tests/native_owl.rs removed from manifest; tests live in inline `#[cfg(test)] mod tests` blocks.

  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 → legacy perch `project` field → 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 — INCLUDING when a legacy perch `project` field happens to contain the literal string `tracked` for a real `tracked` repo (cycle-3 MEDIUM fix)."
      - "`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)."
      - "The new helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` so Plan 04's wrapper route + outbound EVENT stamp can consume the SAME helper — single source of truth (cycle-3 MEDIUM fix)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper + new `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` (reads Self's perch info.json `cwd` field, passes Path to `project_name_from_cwd_path`). Moved here from echo_commune.rs per cycle-3 MEDIUM."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Refactored `build_current_context_blocks` + `resolve_self_project_stamp` consuming the shared resolver via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Belt-and-braces `tracked` rejection at all three layers including context-sensitized legacy-perch branch."
        contains: "resolve_self_project_name_via_info_cwd"
      - 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::resolve_self_project_name_via_info_cwd"
        via: "shared helper reads Self's perch info.json cwd → project_name_from_cwd_path"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "same shared helper — symmetry guarantee with build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/live/wrapper/mod.rs (Plan 04 consumer)"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "Plan 04's wrapper route_inbound_commune_body consumes the SAME helper"
        pattern: "resolve_self_project_name_via_info_cwd"
      - 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 `project` 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 AT EVERY LAYER (cycle-3 MEDIUM fix extends path-context-sensitivity to the legacy-perch branch).

  3. **Cycle-3 MEDIUM (single source of truth):** The resolver helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)`, NOT in `src/owl/echo_commune.rs`. Plan 04's wrapper route_inbound_commune_body consumes the SAME helper from the SAME location. No cross-module private-helper coupling.

  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)` or `resolve_self_project_name_via_info_cwd(&str)` already exist in `src/common/owlery.rs`. From the grep audit (planner side), no such helpers exist — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)`. Task 0 explicitly audits + reports findings before Task 1 creates the helpers.

  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) -> Option<String>` AND `resolve_self_project_name_via_info_cwd(&str) -> Option<String>` in `src/common/owlery.rs` (both `pub(crate)` so wrapper + echo_commune share). Refactors `resolve_self_project_stamp` to consume the new resolver. Adds belt-and-braces path-context-sensitive `"tracked"` rejection at ALL THREE layers including the legacy-perch branch.
  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` + `resolve_self_project_name_via_info_cwd` helpers (both `pub(crate)`); `find_git_root_basename` exposed as `pub(crate)` if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new shared resolver.
  - `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|fn resolve_self_project' -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 helpers around it.
      - If nothing exists → Task 1 creates both helpers from scratch in `src/common/owlery.rs`.

      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` AND `resolve_self_project_name_via_info_cwd` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd|fn .*via_info_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 + resolve_self_project_name_via_info_cwd helpers in owlery.rs + 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_name_via_info_cwd("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns `Some("claude_skill_owl")`.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json is missing returns `None` gracefully.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json `cwd` field is None returns `None` (no fallback in this layer — caller decides next-layer behavior).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/.../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` is None → falls through to legacy perch `project` field. Path-context-sensitive `tracked` rejection at legacy-perch branch: only reject `p == "tracked"` when the SAME info.json's cwd resolves to psyche_dir. If cwd is unavailable, accept the legacy value (could be a real `tracked` repo).
      - `resolve_self_project_stamp("todlando")` legacy-perch path with `project = "tracked"` AND info.json cwd path resolves to a real `tracked/` repo → returns `project = "tracked"` (cycle-3 MEDIUM fix — non-regression).
      - `resolve_self_project_stamp("todlando")` final fallback layer: process cwd is psyche_dir → `stamp.project` cleared (NOT `tracked`).
      - Real-project-named-tracked at final fallback: process cwd is a real `tracked/` repo OUTSIDE psyche_dir → `stamp.project == "tracked"` preserved.
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version) 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).

      Step 3 — Add TWO new helpers in `src/common/owlery.rs` immediately after `find_git_root_basename`. BOTH are `pub(crate)` so wrapper (Plan 04) + echo_commune share the single source of truth:

      First helper — path-based resolver:
      - Name: `project_name_from_cwd_path(path: &std::path::Path) -> Option<String>`
      - Resolution order (mirrors `derive_current_repo_names()` (a)+(c)):
        (a) `find_git_root_basename(path)` — walk parents for `.git`, return that dir's `file_name()`.
        (b) Fallback to `path.file_name()` (D-06 fallback equivalent).
        (c) Path-context-sensitive `tracked` rejection: if resolved name is `"tracked"` AND `path` (canonicalized best-effort) equals or is within `psyche_dir()`, return None. Otherwise pass the name through (real-project-named-tracked non-regression).
      - Returns None if `path` has no file_name component (filesystem root).
      - Doc-comment cites 25.3-01 Defect B2 + locked decision #2 + cycle-3 MEDIUM (single source of truth, used by Plan 04 wrapper too).

      Helper internal: `fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool` — best-effort canonicalize both `path` and `psyche_dir()`, return `path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)`.

      Second helper — perch-info-driven resolver:
      - Name: `resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>`
      - Behavior: read `info_file(self_id)`, parse `InfoJson`, take `cwd` field (Option<String>), pass to `project_name_from_cwd_path`. Return None on any IO/parse/None-field failure (callers handle the layer-2 fallback).
      - Doc-comment: "Plan 04 wrapper route + Plan 01 echo_commune outbound EVENT stamp + build_current_context_blocks ALL consume this helper. Single source of truth (cycle-3 MEDIUM)."

      Step 4 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127). The refactor consumes the new shared helper from `crate::common::owlery::resolve_self_project_name_via_info_cwd` (NOT a private helper inside echo_commune.rs — cycle-3 MEDIUM).

      Resolver chain (top to bottom):
      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, set `s.project = name` and return.
      2. **Layer 2 (legacy perch `project` field — context-sensitized):** call `read_self_project_from_info(self_id)`. If `Some(p)` and `!p.is_empty()`:
         - If `p == "tracked"`: read the SAME perch info.json's `cwd` field (re-parse — or refactor `read_self_project_from_info` to return both fields). If the cwd path resolves via `project_name_from_cwd_path` to `None` (i.e. the cwd IS psyche_dir), reject — fall through to layer 3. Otherwise accept `p` (real `tracked` repo non-regression).
         - If `p != "tracked"`: accept unconditionally.
         - Set `s.project = p` and return.
      3. **Layer 3 (final cwd-stamp fallback):** `s = crate::common::git::stamp()`. If `s.project == "tracked"`, read process cwd via `std::env::current_dir()`. If `project_name_from_cwd_path(&cwd)` returns `None`, clear `s.project = String::new()` (the D-06 false positive). Otherwise leave `s.project` as-is (real `tracked` repo).

      Helper changes:
      - If `read_self_project_from_info` currently returns only `Option<String>`, EITHER extend it to also return cwd OR add a sibling helper `read_self_perch_info(self_id: &str) -> Option<InfoJson>` (preferred — returns the parsed struct so layer 2 can peek cwd). Document choice in SUMMARY.

      Remove the TODO comment at echo_commune.rs:108-109 (now fulfilled).

      Step 5 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s):
      - `owlery.rs` tests live in that file's existing `mod tests`.
      - `echo_commune.rs` tests live in one of the existing blocks (line 1127, 1703, 1894, 2081 — choose the resolver-shaped block; if no fit, use line 1127).

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

      Tests (10 total):
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — 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).
      - `owlery::tests::resolve_self_project_name_via_info_cwd_reads_cwd_field` — write fake perch info.json with cwd → temp .git repo; assert helper returns Some(repo_basename). `#[serial]`.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_info_missing` — assert no panic, returns None.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_cwd_field_none` — assert no panic, returns None.
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write fake perch info.json with cwd → temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` — info.json carries `project = "tracked"` AND `cwd` pointing to a real `tracked/` repo OUTSIDE psyche_dir; assert stamp.project == "tracked" (cycle-3 MEDIUM regression guard). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_rejects_tracked_when_cwd_is_psyche` — info.json carries `project = "tracked"` AND `cwd` pointing to psyche_dir; assert layer-2 rejected, falls through to layer 3. `#[serial]`.
      - `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; cargo test --release resolve_self_project 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, `pub(crate)`.
      - `resolve_self_project_name_via_info_cwd` exists in `src/common/owlery.rs`, `pub(crate)` (cycle-3 MEDIUM: single source of truth, NOT in echo_commune.rs).
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` consumes `crate::common::owlery::resolve_self_project_name_via_info_cwd` at layer 1.
      - All three resolver layers filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces; layer 2 now context-sensitized per cycle-3 MEDIUM).
      - Real-project-named-tracked tests pass at BOTH layer 2 and layer 3 (regression guards).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - All ~10 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 shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 a layered resolver chain (consuming the SHARED helper from `crate::common::owlery`):

      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, use it.
      2. **Layer 2 (process-cwd fallback for callers like signoff prompt-build that may run from a real project cwd):** if layer 1 returns None, call `owlery::derive_current_repo_names().into_iter().next()`. If that returns `"tracked"`, verify via `project_name_from_cwd_path(&std::env::current_dir()?)` — if the helper returns None (psyche_dir false positive), drop. Otherwise accept (real `tracked` repo).
      3. **Result:** `Option<String>` resolved project name. If None, emit NO `CURRENT_PROJECT_CONTEXT` block.

      Output format when project resolves:
      `\n\nCURRENT_PROJECT_CONTEXT (name=<X>):\n<body>`
      where `<body>` is `std::fs::read_to_string(owlery::project_worktree_path(&name).join(format!("{}.md", self_id)))` or the literal `"(none — first commune in project)"` on read failure.

      The `(name=<X>)` parenthetical is the debug/test-visible token codex MEDIUM requested for smoke verification.

      Update the doc comment on `build_current_context_blocks` (lines 372-397):
      - Cite Defect B2 fix and the new resolver order (info-cwd via shared helper → 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`) AND Plan 04 wrapper route_inbound_commune_body consume the SAME shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 → 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 `crate::common::owlery::resolve_self_project_name_via_info_cwd` first (shared helper, NOT a private function).
      - 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 + cycle-3 single-source-of-truth note.
    </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|resolve_self_project_name_via_info_cwd' -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.
      - Plan 04 wrapper consumer (NOT yet present — will appear in Plan 04). Confirm the helper signature is wrapper-friendly (pub(crate), no echo_commune-private dependencies).
      - 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), 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 — Confirm `resolve_self_project_name_via_info_cwd` visibility is `pub(crate)` (not `fn`-private). This is the cycle-3 MEDIUM gate for Plan 04 reuse.

      Step 5 — 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 6 — 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."

      Step 7 — Write a one-line "Plan 04 handoff note": "Plan 04's wrapper route MUST consume `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` — same shared helper as build_current_context_blocks + resolve_self_project_stamp. NO duplicated resolver logic in wrapper."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5; Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' | Measure-Object | Select-Object -ExpandProperty Count</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.
      - `resolve_self_project_name_via_info_cwd` visibility verified as `pub(crate)` (Plan 04 reuse gate).
      - Plan 02 handoff note + Plan 04 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 `resolve_self_project_name_via_info_cwd_returns_none_when_info_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 at ALL THREE layers (cycle-3 MEDIUM extension): filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit tests at each layer guard 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). Pin to major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW in owlery.rs) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW in owlery.rs, `pub(crate)`) is the perch-info-driven entry point — SHARED by Plan 04.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback, now context-sensitized for `tracked`.
     - `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: `build_current_context_blocks`, `derive_current_repo_names`, `resolve_self_project_stamp`, and `resolve_self_project_name_via_info_cwd` callers documented.

  3. `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 04 reuse gate).

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

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

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

  <success_criteria>
  - `project_name_from_cwd_path(path)` AND `resolve_self_project_name_via_info_cwd(self_id)` both exist in `src/common/owlery.rs` as `pub(crate)`.
  - `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, INCLUDING context-sensitized legacy-perch branch (cycle-3 MEDIUM); real-project-named-tracked non-regression preserved at every layer.
  - `cargo test --release` passes including all 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 [context-sensitized] → cwd-stamp), each with its `tracked` filter point.
  - Confirmation that `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` (NOT in echo_commune.rs).
  - Caller audit table for `build_current_context_blocks`, `derive_current_repo_names`, and `resolve_self_project_name_via_info_cwd`.
  - Codex review resolution table: each cycle-2 MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note (at BOTH layer 2 and layer 3).
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Plan 04 handoff note (wrapper consumes the shared helper from owlery.rs).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>
  </content>
  </invoke>
  ---

  ### 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
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (static prompt-contract test scope): "The static prompt test checks for absence of
  #     `<project-context name=` only in psyche.md, while the plan's invariant says no name examples anywhere
  #     in psyche plus skill files. Add the same guard over commune.md and signoff/SKILL.md." → Task 3 Step 4
  #     extends the static prompt-contract test to ALSO assert no `<project-context name=` substring exists
  #     in `plugin/spt/skills/commune/commune.md` AND `plugin/spt/skills/signoff/SKILL.md`. The test now reads
  #     three files and asserts the no-name guard against each.
  #   MEDIUM (smoke verification rigor): "The plan still depends on a human/LLM smoke cycle to prove artifact
  #     materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking
  #     verification, not just a deployment smoke." → Task 5 already uses `checkpoint:human-verify gate=
  #     "blocking"` (cycle 2 was correct here). Reinforced in `<done>` block: operator-verify is a hard gate;
  #     a `Test-Path` false MUST be reported as a failure, not a soft warning.
  #   LOW (task numbering gap): "Task numbering skips from Task 0 to Task 2." → Task numbers normalized:
  #     Task 1 (Read Plan 01 + Plan 03 SUMMARYs), Task 2 (psyche.md edits), Task 3 (skill files),
  #     Task 4 (deploy + smoke), Task 5 (human-verify checkpoint). Sequential. No gaps.
  #
  # Cycle-2 HIGHs preserved (already resolved):
  #   - Option A locked (no-name `<project-context>` shape).
  #   - "SPT routes; you emit" phrasing.
  #   - Plan 03 encoding-contract reference (now unambiguously DECODED per cycle-3 Plan 03 lock).
  #   - depends_on: [25.3-01, 25.3-03].
  #
  # Plan 03 handoff update (cycle-3): Plan 03 LOCKED Option A. Body state at LLM-stdin = DECODED. psyche.md
  # absorption rule 5 (Encoding assumption) now states this literally: "At the LLM-stdin boundary, the EVENT
  # envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT
  # re-encode."

  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 DECODED form (Plan 03 LOCKED Option A) — so future editors can't accidentally re-encode."
      - "Static prompt-contract test (cycle-3 MEDIUM extension) asserts NO `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md — the no-name guard covers ALL three files."
    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"
      - path: "tests/native_owl.rs"
        provides: "Static prompt-contract test (cycle-3 extension) asserts no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md."
        contains: "psyche_md_contains_25_3_absorption_section"
    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: DECODED at LLM-stdin per Option A LOCKED)"
        via: "body-state-at-LLM-stdin = DECODED literally"
        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 03 LOCKED (cycle-3):** body state at LLM-stdin = DECODED. psyche.md absorption rule 5 states this literally. No more checkpoint-dependent placeholder.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (DECODED at LLM-stdin); this plan's absorption rules reference that state 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 (DECODED at LLM-stdin).
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - `tests/native_owl.rs` — static prompt-contract test extended (cycle-3) to assert no `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md.
  - 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 (DECODED at LLM-stdin per Option A LOCKED).

  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; cycle-3 LOCKED Option A):
  - Body state at LLM stdin = DECODED. psyche.md absorption rule 5 references this literally.

  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 1: 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 is DECODED (cycle-3 LOCKED Option A). psyche.md absorption rule 5 will state this literally: "At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT re-encode."

      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 (`CURRENT_PROJECT_CONTEXT (name=<X>):`).
      - Plan 03 body-state-at-LLM-stdin extracted (DECODED — locked Option A).
      - 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, tests/native_owl.rs</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 Plan 03 LOCKED Option A).** At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags (you see `<`, not `&lt;`). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume. User-prose entities inside body content (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched — they are character content, not transport encoding (cycle-3 reframed entity invariant).

      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 (cycle-2 MEDIUM + cycle-3 MEDIUM extension). Add a test in `tests/native_owl.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 in psyche.md");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("DECODED") || content.contains("encoding contract"),
                  "must reference Plan 03 contract (DECODED at LLM-stdin)");
      }

      /// Cycle-3 MEDIUM: extend the Option-A no-name guard to commune.md + signoff/SKILL.md.
      /// The codex review noted that the cycle-2 test only covered psyche.md; the plan's
      /// invariant says ALL three files. This test fills that gap.
      #[test]
      fn skill_files_have_no_project_context_name_attr() {
          let commune = std::fs::read_to_string("plugin/spt/skills/commune/commune.md")
              .expect("commune.md must exist");
          let signoff = std::fs::read_to_string("plugin/spt/skills/signoff/SKILL.md")
              .expect("signoff/SKILL.md must exist");
          assert!(!commune.contains("<project-context name="),
                  "Option A locked: no name= attr in commune.md (cycle-3 MEDIUM)");
          assert!(!signoff.contains("<project-context name="),
                  "Option A locked: no name= attr in signoff/SKILL.md (cycle-3 MEDIUM)");
      }
      ```

      These are static prompt-contract tests — not full LLM-output goldens, but they guard against accidental phrase drift in future edits across ALL three documents.
    </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; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists in psyche.md 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 rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - TWO static prompt-contract tests pass: one for psyche.md, one for commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
    </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.

      Self-verification: after edits, the `skill_files_have_no_project_context_name_attr` test from Task 2 MUST pass against these freshly-edited files. Run it as a self-check before considering this task done.
    </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 '<project-context name=' -SimpleMatch | Measure-Object | Select-Object -ExpandProperty Count; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 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.
      - `skill_files_have_no_project_context_name_attr` test passes (cycle-3 MEDIUM gate).
    </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 (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 DECODED state per Plan 03 Option A LOCKED. Apply the cycle-3 reframed invariant: NO `&lt;EVENT`, `&lt;/EVENT`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;` substrings in the body. User-prose entities (if any in todlando's body) pass through.
    </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 DECODED state (no forbidden transport-encoded envelope-tag substrings).
    </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 (cycle-3 MEDIUM: layer-2 legacy-perch field context-sensitized); shared helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs`.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 reframed entity invariant (no transport-encoded envelope tags at LLM-stdin); dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension); 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 add wrapper-direct routing with explicit RouteOutcome + precedence-metadata stale-overwrite mitigation).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`). HARD GATE — false means failure (cycle-3 MEDIUM: this is a real blocking verification, not a soft warning).
      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;`) — cycle-3 reframed invariant verification (no transport-encoded envelope-tag substrings).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform (no transport-encoded envelope tags), 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 tests assert no `<project-context name=` substring exists in psyche.md AND commune.md AND signoff/SKILL.md (cycle-3 MEDIUM extension). |
  | 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. HARD GATE.
  - `live_context.md` for todlando continues to update normally.
  - TWO static prompt-contract tests pass (psyche.md + skill files — cycle-3 MEDIUM extension).
  - Encoding-contract smoke confirms DECODED state at LLM-stdin (cycle-3 reframed invariant).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing; rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
  - 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 (HARD GATE).
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract tests assert no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
  </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 + DECODED 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.
  - Static prompt-contract test pass evidence (both tests).
  - Codex review resolution table: each cycle-2 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 adds wrapper-direct routing with explicit RouteOutcome (no false project_routed) and precedence-metadata stale-overwrite mitigation."
  </output>
  </content>
  </invoke>
  ---

  ### 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: true
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGH + MEDIUMs
  #
  # Cycle-2 codex HIGH addressed:
  #   HIGH (entity invariant too strong): "Plan 03 says the LLM stdin should have no `&amp;` entities in the
  #     end-to-end test. That is too strong if user content legitimately contains a literal encoded string
  #     like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not
  #     'no entities anywhere.'" → Task 3 Step 6 REFRAMED. The end-to-end invariant now asserts NO TRANSPORT-
  #     ENCODED ENVELOPE TAGS at LLM-stdin. The forbidden substring set is EXPLICIT:
  #       `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`,
  #       `&lt;project-context`, `&lt;/project-context`,
  #       `&lt;live-context`, `&lt;/live-context`,
  #       `&lt;br&gt;`
  #     User-content entities NOT forming envelope tags (e.g. user prose containing `&amp; rules!`, `&lt; 5`,
  #     or a literal HTML snippet `&amp;copy;`) MUST pass through untouched. The test fabricates both a body
  #     with envelope tags AND a body with user-prose entities; asserts forbidden tags absent AND user prose
  #     present byte-identical.
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (event_body_unescape `<br>` round-trip framing): "event_body_unescape uses `<br>` → newline first,
  #     which means a user's literal `<br>` text will not round-trip through event_body_escape because `<br>`
  #     is escaped as `&lt;br&gt;`, not `<br>`. The test named 'literal_br' needs to distinguish transport
  #     newline markers from user literal `<br>`." → Traced manually: the existing encode order DOES preserve
  #     round-trip. Input `"<br>"` → escape:`& < > " \n→<br>` order → `"&lt;br&gt;"` (the `<` is escaped
  #     BEFORE the `\n→<br>` step ever sees a `<br>` to insert). Decode order: `<br>→\n` FIRST sees no `<br>`
  #     in `"&lt;br&gt;"`; then `&lt;→<` → `"<br>"`. Round-trip CORRECT.
  #     Input `"\n"` → escape → `"<br>"` → decode: `<br>→\n` → `"\n"`. ALSO CORRECT.
  #     So the cycle-2 concern was about TEST CLARITY, not a real round-trip break. Task 3 Step 4 now has
  #     TWO clearly-named tests covering both cases:
  #       - `event_body_round_trip_preserves_user_literal_br_text` (input contains literal `<br>` characters
  #         as user prose)
  #       - `event_body_round_trip_decodes_transport_br_to_newline` (input contains a literal `\n` character
  #         that gets transport-encoded as `<br>` and must decode back to `\n`)
  #     The test asserts byte-equality across both round-trips. The encode/decode order is locked AS-IS
  #     (amp-first encode + amp-last decode + `<br>` first in decode + `\n→<br>` last in encode).
  #   MEDIUM (Option A/B checkpoint redundant): "The plan still includes a blocking decision between Option A
  #     and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If
  #     convergence is desired, lock Option A unless there is a real reason to keep the checkpoint." → REMOVED
  #     the checkpoint:decision task. Option A (decode at wrapper LLM-stdin boundary) is LOCKED. Task 2 is
  #     gone. Plan 03 now has only Tasks 1, 3, 4. The freed slot is unused (Plan 03 stays at 3 tasks; the
  #     end-to-end invariant + chunk-boundary tests merged into Task 3 Steps 5-6 which were already there).
  #     Plan 04's body-state-at-process_file_drop is now an UNAMBIGUOUS handoff: DECODED (filesystem read of
  #     user-written commune file yields literal tags; no decode needed in Plan 04 itself).
  #
  # Cycle-2 LOW addressed:
  #   LOW: "Large contract doc in poll.rs may age poorly." → Task 3 Step 3 keeps the 80-line extraction
  #     threshold. Decision deterministic.
  #
  # Cycle-2 strengths preserved:
  #   - Decode happens AFTER EVENT framing parse (NOT global string unescape). Documented + tested.
  #   - Reframed invariant: "one body decode per transport traversal" (NOT "one site only").
  #   - EVENT-PART chunk-boundary tests (Task 3 Step 5).
  #   - PowerShell-only commands (no Bash heredocs).

  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 contains NO TRANSPORT-ENCODED ENVELOPE TAGS — the forbidden substrings are: `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;`. User-prose entities NOT forming envelope tags (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched."
      - "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)."
      - "`<br>` round-trip: user-prose literal `<br>` text and transport newline-as-`<br>` are BOTH handled correctly by the current encode order (`& < > \" \\n→<br>`) + decode order (`<br>→\\n`, `&lt;`, `&gt;`, `&quot;`, `&amp;`). Two distinct tests prove this (cycle-3 MEDIUM)."
      - "Option A is LOCKED: decode happens at the wrapper LLM-stdin boundary AFTER body extraction. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04)."
    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 TRANSPORT-ENCODED ENVELOPE TAGS in final body; user-prose entities pass through; 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:
  - **Option A is LOCKED (cycle-3):** decode happens at the wrapper LLM-stdin boundary AFTER body extraction. NO checkpoint:decision in this plan — the choice is settled. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written commune file yields literal tags).
  - **Plan reorder:** This plan executes BEFORE Plan 02. Plan 02's psyche.md absorption rules reference the locked Option A.
  - **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.
  - **Cycle-3 HIGH (entity invariant reframe):** the end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched.
  - **Cycle-3 MEDIUM (`<br>` round-trip clarity):** TWO distinct tests cover (a) user-prose literal `<br>` text and (b) transport newline-as-`<br>` — both round-trip correctly under the existing encode order.

  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 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the wrapper LLM-stdin boundary (Option A LOCKED); remove any double-encodes. Writes the contract doc. Adds tests including the cycle-3 reframed entity invariant + the two-distinct-`<br>` tests + EVENT-PART chunk-boundary regressions.
  3. (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. With Option A locked, `process_file_drop` sees DECODED body — the wrapper's filesystem read of the user-written commune file yields literal tags directly. No decode pass needed in Plan 04 itself. Task 4 explicitly states this in the Plan 04 handoff note.
  </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:685-739. -->

  ```rust
  // src/owl/poll.rs:685 (encode order — amp first, \n→<br> LAST)
  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 (amp-last invariant).
  ```

  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. Option A LOCKED: site will live at the wrapper LLM-stdin boundary AFTER body extraction.

  Round-trip trace (cycle-3 MEDIUM `<br>` clarity):
  - Input `"\n"` → escape (amp-first, `\n→<br>` LAST) → `"<br>"` → decode (`<br>→\n` FIRST, then `&lt;` etc.) → `"\n"`. ✓
  - Input `"<br>"` (user prose) → escape: `&` `<` `>` `"` `\n` order → after `<` step: `"&lt;br&gt;"` → `\n` step: no `\n` so unchanged → `"&lt;br&gt;"` → decode: `<br>→\n` finds no `<br>` in `"&lt;br&gt;"` → `&lt;→<` → `"<br>"`. ✓
  - Both round-trips byte-equal. The existing encode/decode order is correct AS-IS.
  </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="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site at wrapper LLM-stdin boundary (Option A LOCKED), write contract doc, add tests including cycle-3 reframed entity invariant + dual-`<br>` tests + 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. Option A LOCKED: body decode happens at the wrapper LLM-stdin boundary AFTER body extraction.
      - 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 wrapper LLM-stdin 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 ALL of: bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>` (user prose), literal `\n` (transport newline).
      - 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).
      - Cycle-3 reframed entity invariant: end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched (byte-equality).
      - Cycle-3 dual-`<br>` tests: BOTH `user-prose literal <br>` round-trip AND `transport \n encoded as <br>` round-trip are byte-equal.
      - 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.
      ///
      /// Round-trip cases (cycle-3 MEDIUM):
      /// - Input `"\n"` → escape → `"<br>"` → unescape → `"\n"`. ✓
      /// - Input `"<br>"` (user prose) → escape → `"&lt;br&gt;"` → unescape → `"<br>"`. ✓
      /// Both byte-equal under the current encode order (`& < > " \n→<br>` LAST).
      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 per Option A LOCKED. 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 wrapper LLM-stdin boundary (`resume_session_with_exit` call site) — 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 + Option A locked + 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 (wrapper LLM-stdin boundary AFTER body extraction; amp-last invariant; Option A LOCKED).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see: ENCODED bodies throughout (Option A).
      - Plan 04 process_file_drop body state: DECODED (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04).
      - 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`:
      - `event_body_unescape_inverts_event_body_escape_for_html_content` — body with `<`, `>`, `"`, `&`.
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content` — body starting with `&amp;` (amp-last invariant guard).
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags` — body containing `<live-context>...</live-context>`.
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` — body containing `</EVENT>` — must NOT reframe.
      - **Cycle-3 MEDIUM dual-`<br>` tests** (TWO distinct cases):
        - `event_body_round_trip_preserves_user_literal_br_text` — input is user prose containing literal `<br>` text (e.g. `"line one<br>line two"` where `<br>` is meant as character content, NOT a newline marker); after escape→unescape, the literal `<br>` characters round-trip byte-equal.
        - `event_body_round_trip_decodes_transport_br_to_newline` — input contains a literal `\n` character; after escape, body contains `<br>` (the transport marker); after unescape, the `<br>` decodes back to `\n` byte-equal.
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` — covers the asymmetric site directly.

      Step 5 — EVENT-PART chunk-boundary tests (cycle-2 MEDIUM, preserved):
      - `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`) — **CYCLE-3 HIGH REFRAMED INVARIANT**:

      Build TWO body scenarios:

      **Scenario A (envelope-tag body):** Fabricate a commune body containing literal `<live-context>marker-live-aaa</live-context>` AND literal `<project-context>marker-proj-bbb</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 NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin. The EXPLICIT FORBIDDEN SUBSTRING SET (test must check every one):
      ```rust
      let forbidden = [
          "&lt;EVENT",
          "&lt;/EVENT",
          "&lt;EVENT-PART",
          "&lt;/EVENT-PART",
          "&lt;project-context",
          "&lt;/project-context",
          "&lt;live-context",
          "&lt;/live-context",
          "&lt;br&gt;",
      ];
      for tag in forbidden {
          assert!(!final_body.contains(tag),
                  "LLM stdin must not contain transport-encoded envelope tag `{}` (cycle-3 reframed invariant)", tag);
      }
      ```

      Additionally assert that inner envelope tags ARE present as LITERAL `<` / `>`:
      ```rust
      assert!(final_body.contains("<live-context>marker-live-aaa</live-context>"));
      assert!(final_body.contains("<project-context>marker-proj-bbb</project-context>"));
      ```

      **Scenario B (user-prose entity body):** Fabricate a commune body where the user prose contains literal entity-shaped strings that are NOT envelope tags:
      - `"This rule about &amp; in URLs is documented."`
      - `"Use &lt; 5 in math notation."`
      - `"HTML entity for copyright is &amp;copy;."`

      Run through the same pipeline. Assert these byte-identical strings are PRESENT in the final body at LLM-stdin (user prose passes through untouched — cycle-3 HIGH reframe):
      ```rust
      assert!(final_body.contains("This rule about &amp; in URLs is documented."),
              "user-prose entity must pass through untouched (cycle-3 HIGH reframe)");
      assert!(final_body.contains("Use &lt; 5 in math notation."));
      assert!(final_body.contains("HTML entity for copyright is &amp;copy;."));
      ```

      Combined assertion logic: the forbidden-substring set is checked AGAINST envelope-tag patterns ONLY (the `&lt;EVENT`, `&lt;br&gt;`, etc.). User prose containing standalone `&amp;` or `&lt; 5` (with a space-separator that prevents tag formation) passes through.

      Document this distinction in the test's doc comment:
      > Cycle-3 HIGH reframe: the invariant is "no transport-encoded ENVELOPE TAGS at LLM-stdin", NOT "no entities at LLM-stdin". User prose may legitimately contain `&amp;`, `&lt;`, `&gt;`, `&quot;` as character content; those round-trip through the transport because the encode/decode pair is symmetric AND those characters never form structural envelope tokens by themselves.
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30; cargo test --release event_body_round_trip 2>&1 | Select-Object -Last 30; cargo test --release event_part_chunk_boundary 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 per Option A LOCKED.
      - 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."
      - 4 base unit tests + 2 dual-`<br>` tests (cycle-3 MEDIUM) + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + Scenario B) pass.
      - Cycle-3 HIGH reframed invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
      - 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).

      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 transport-encoded envelope tags (cycle-3 reframed invariant)
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY: "Option A LOCKED. `process_file_drop` receives a file_drop EVENT and reads the user-written commune file directly from disk (raw filesystem read yields literal tags). The body is in DECODED state when it reaches `route_inbound_commune_body`. NO decode pass needed in Plan 04 itself."

      Step 6 — Write the "Plan 02 handoff note" in the SUMMARY: "Option A LOCKED. Body state at LLM-stdin = DECODED. psyche.md absorption rules should reference this state literally (LLM sees literal `<live-context>` / `<project-context>` tags). The reframed entity invariant is: 'no transport-encoded envelope tags at LLM-stdin'; user-prose entities pass through."
    </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), Plan 04 handoff note (DECODED at process_file_drop), and Plan 02 handoff note (DECODED at LLM-stdin + reframed invariant).
    </done>
  </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 decoded AFTER body extraction (Option A LOCKED). |
  | 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-T5 | Tampering | User-prose entity `&amp;` is mistakenly stripped or transformed mid-traversal (cycle-3 HIGH reframe) | mitigate | Reframed invariant test Scenario B asserts user-prose `&amp;`, `&lt; 5`, `&amp;copy;` pass through byte-equal. Encode/decode symmetry guarantees this; the forbidden-substring list targets envelope tag PATTERNS only, never standalone entities. |
  | 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 per Option A LOCKED.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 4 base unit tests + 2 dual-`<br>` tests + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + B) pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - Cycle-3 HIGH reframed entity invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
  - 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 handoff: body state at `process_file_drop` = DECODED (Option A LOCKED; filesystem read).
  - Plan 02 has an unambiguous handoff: body state at LLM-stdin = DECODED (Option A LOCKED); psyche.md absorption rules reference this literally.
  - Cycle-3 HIGH reframed invariant verified by Scenario A + B end-to-end test.
  </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.
  - Locked option: A (no checkpoint in this plan).
  - 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 2 dual-`<br>` tests + Scenario A + Scenario B end-to-end).
  - Plan 02 handoff note: "Body state at LLM-stdin = DECODED. Reframed invariant: no transport-encoded envelope tags at LLM-stdin; user-prose entities pass through."
  - Plan 04 handoff note: "Body state at process_file_drop = DECODED (filesystem read). No decode pass needed in Plan 04."
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each cycle-2 HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>
  </content>
  </invoke>
  ---

  ### 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/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGHs + MEDIUMs
  #
  # Cycle-2 codex HIGHs addressed (the 3 NEW ones cycle-2 introduced):
  #
  #   HIGH 1 (false project_routed outcome): "route_inbound_commune_body returns project_routed: true
  #     whenever route_two_slice returns Ok. But route_two_slice may Ok(()) and silently skip the project
  #     slice when project name doesn't resolve. Result: logs/tests claim project persistence when none
  #     happened." → Task 2 introduces an explicit `RouteOutcome` enum returned by the inner route functions.
  #     `route_two_slice` (in src/owl/echo_commune.rs:237 — CYCLE-3 FILE PATH CORRECTION) gains a sibling
  #     `route_two_slice_outcome` that returns granular state:
  #       enum SliceWriteState { Written, SkippedNoSlice, SkippedNoProjectName, IoError(String) }
  #       struct RouteOutcome { live: SliceWriteState, project: SliceWriteState }
  #     `route_inbound_commune_body` propagates this. Logs say `project=written` ONLY when the project
  #     slice actually hit disk. Tests assert specific `SliceWriteState` variants.
  #
  #   HIGH 2 (monotonic guard underspecified): "strict-subset by nonblank lines heuristic. Rejects valid
  #     summarization/compaction (psyche.md ALREADY teaches LLM to summarize), AND misses stale rewrites
  #     that paraphrase or reorder same content. Wrong tool for the job." → REPLACED with explicit
  #     PRECEDENCE METADATA. Each `live_context.md` / `projects/<name>/<self_id>.md` write carries a marker
  #     comment line at the file head:
  #       <!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->
  #     The next write reads the existing marker:
  #       - direct write: ALWAYS proceeds + updates marker (source-of-truth).
  #       - llm write: reads existing marker. If existing `source=direct` AND existing `routed_at` is
  #         within the LAST echo_commune fire window (configurable; default = 60 seconds), REJECT the
  #         llm write + emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id}` warning.
  #         If existing `routed_at` is older than the window OR source=llm OR no marker present,
  #         proceed (legitimate summarization wins).
  #     This handles BOTH: (a) legitimate LLM summarization wins when newer than the staleness window,
  #     (b) stale post-direct-route LLM emit can't erase the direct-routed content within the protection
  #     window.
  #
  #   HIGH 3 (resolver helper placement): "Resolver helper placement/dependency is muddy: Plan 04 says
  #     route via Plan 01's resolver in echo_commune.rs, but wrapper should not depend on an owl module-
  #     private helper. Move the resolver to common/owlery.rs or common/tracked.rs." → RESOLVED by
  #     Plan 01 cycle-3 fix: `resolve_self_project_name_via_info_cwd` now lives in `src/common/owlery.rs`
  #     as `pub(crate)`. Plan 04 consumes it from there. NO cross-module private-helper coupling.
  #
  # Cycle-2 MEDIUMs addressed:
  #
  #   MEDIUM (files_modified omits echo_commune.rs): "Plan 04's files_modified omits src/owl/echo_commune.rs,
  #     but Task 2 wires outbound echo_commune to route_two_slice_monotonic there." → CORRECTED:
  #     files_modified now lists `src/owl/echo_commune.rs`. CYCLE-3 ADDITIONAL CORRECTION: `route_two_slice`
  #     itself ALREADY lives in `src/owl/echo_commune.rs:237` (NOT in `src/common/tracked.rs` as cycle-2
  #     prose stated). All references corrected.
  #
  #   MEDIUM (route failures need clear surface): preserved from cycle 2 — structured WARN logs with
  #     `[FILE-DROP]` and new `[ROUTE-GUARD]` prefixes; operator-visible via Select-String.
  #
  #   MEDIUM (testable seam): preserved from cycle 2 — `route_inbound_commune_body` seam is unit-testable
  #     without mocking resume_session_with_exit.
  #
  #   LOW (PowerShell only): preserved from cycle 2 — all Bash heredocs eliminated.
  #
  # Cycle-2 HIGHs/decisions preserved:
  #   - Option 1 LOCKED (wrapper direct-route before LLM handoff).
  #   - Receiver-side resolver via Plan 01's shared helper.
  #   - Phase-level invariant test (Task 5).
  #   - Final consolidated deploy.

  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 BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` (info.json `cwd` → `project_name_from_cwd_path`) — SAME shared helper as build_current_context_blocks + resolve_self_project_stamp. No third resolver site (cycle-3 HIGH 3 fix)."
      - "`route_inbound_commune_body` returns an EXPLICIT `RouteOutcome` enum reflecting whether each slice ACTUALLY hit disk — `SliceWriteState::Written`, `SkippedNoSlice`, `SkippedNoProjectName`, `IoError(String)`. Logs say `project=written` ONLY when the project slice actually persisted (cycle-3 HIGH 1 fix). No false-positive `project_routed: true`."
      - "STALE-OVERWRITE MITIGATION via PRECEDENCE METADATA (cycle-3 HIGH 2 fix): every write carries a `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker at the file head. Direct writes always proceed + update marker. LLM writes within the protection window (default 60s) after a direct write are suppressed with a `[ROUTE-GUARD]` warning. LLM writes outside the window proceed (legitimate summarization wins). NOT a strict-subset heuristic — the old heuristic over-rejected summarization and under-rejected paraphrase."
      - "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 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). Subsequent stale LLM emit within the protection window does NOT erase the marker."
      - "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`. Tests assert on specific `SliceWriteState` variants, not on boolean conflations."
      - "files_modified manifest correctly lists `src/owl/echo_commune.rs` (where `route_two_slice` actually lives — cycle-3 file-path correction)."
    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). RouteOutcome propagation. Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/owl/echo_commune.rs"
        provides: "New `route_two_slice_outcome` (returns granular RouteOutcome enum, cycle-3 HIGH 1) + new `route_two_slice_with_precedence` (precedence-metadata stale-overwrite mitigation, cycle-3 HIGH 2). Outbound `run_echo_commune` rewired to use the precedence-metadata variant."
        contains: "RouteOutcome|route_two_slice_with_precedence|SliceWriteState"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + precedence-metadata stale-overwrite mitigation tests + phase-level invariant test."
        contains: "route_inbound_commune_body|precedence"
    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/common/owlery.rs::resolve_self_project_name_via_info_cwd (Plan 01 shared helper)"
        via: "receiver-side project resolver — same source as build_current_context_blocks + resolve_self_project_stamp"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::route_two_slice_outcome (NEW)"
        to: "Granular SliceWriteState propagation (cycle-3 HIGH 1)"
        via: "explicit per-slice write state, no false project_routed=true"
        pattern: "SliceWriteState"
      - from: "src/owl/echo_commune.rs::route_two_slice_with_precedence (NEW)"
        to: "precedence metadata marker at file head"
        via: "spt:source=direct|llm spt:routed_at=ISO timestamp comparison"
        pattern: "spt:source|spt:routed_at"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with all cycle-2 + cycle-3 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 + cycle-3 fixes:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies BEFORE LLM handoff. Option 2 (round-trip-via-LLM) is DISFAVORED. No checkpoint:decision — choice is settled. (Task 1 is removed; tasks renumbered.)

  - **Receiver-side resolver via shared helper (cycle-3 HIGH 3).** Routing uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` — the SAME shared helper as `build_current_context_blocks` + `resolve_self_project_stamp`. Single source of truth. NO duplicated resolver logic in wrapper.

  - **Explicit RouteOutcome (cycle-3 HIGH 1).** `route_inbound_commune_body` returns granular per-slice write state. NO false `project_routed: true` when the project slice silently skipped.

  - **Precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).** Replace strict-subset heuristic with `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker. Direct writes always proceed. LLM writes within the protection window after a direct write are suppressed.

  - **Testable seam (codex MEDIUM).** `route_inbound_commune_body(self_id, body, source)` is unit-testable without mocking `resume_session_with_exit`.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  - **File-path correction (cycle-3).** `route_two_slice` lives in `src/owl/echo_commune.rs:237`, NOT `src/common/tracked.rs`. files_modified updated.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in DECODED form (Plan 03 LOCKED Option A — filesystem read of user-written commune file yields literal tags). NO decode pass needed in Plan 04.

  Output:
  - `route_inbound_commune_body` helper extracted as testable seam in `src/live/wrapper/mod.rs`.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs`.
  - `route_two_slice_outcome` sibling that returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` sibling that applies precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).
  - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence`.
  - End-to-end test demonstrating synchronous inbound persistence + stale-overwrite mitigation.
  - 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/owl/echo_commune.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/owl/echo_commune.rs:237 (CYCLE-3 FILE-PATH CORRECTION — `route_two_slice` lives HERE, not in `src/common/tracked.rs`):
  ```rust
  pub(crate) fn route_two_slice(
      self_id: &str,
      raw: &str,
      kind: &str,
      short_for_subject: &str,
  ) -> Result<(), String>;
  ```
  - Parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).
  - Returns `Ok(())` whether or not the project slice actually wrote (silently skips when no project resolves — this is the cycle-3 HIGH 1 false-positive surface).
  - Internally calls `route_project_slot` (private) at line 305 for the project half.

  From src/common/owlery.rs (Plan 01 outcome — cycle-3 HIGH 3 fix):
  - `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01 (cycle-3 placement: in owlery.rs, NOT echo_commune.rs). 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. Single shared helper across echo_commune + wrapper + outbound stamp.

  From Plan 03 outcome (cycle-3 LOCKED Option A):
  - Body state at wrapper file_drop boundary = DECODED. Filesystem read of user-written commune file yields literal `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`. NO decode pass needed.

  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 1: 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 = DECODED (Plan 03 LOCKED Option A)."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      Per Plan 03 LOCKED Option A, the body at process_file_drop is DECODED (filesystem read yields literal tags). NO decode pass needed in Task 2.

      Also confirm `crate::common::owlery::resolve_self_project_name_via_info_cwd` exists as `pub(crate)` (Plan 01 cycle-3 placement gate). Grep:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch
      ```
      Must return ≥1 match. If 0, Plan 01 did not land the helper in the right module — block here and fix Plan 01 before proceeding.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md; (Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read; body state at process_file_drop confirmed DECODED.
      - Shared helper `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 01 cycle-3 gate).
      - Task 2 has both decode-or-not answer (NO decode) and resolver-import path.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Add RouteOutcome enum + route_two_slice_outcome + route_two_slice_with_precedence + extract route_inbound_commune_body seam + wire process_file_drop</name>
    <files>src/owl/echo_commune.rs, src/live/wrapper/mod.rs</files>
    <behavior>
      - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs` (pub(crate) so wrapper can pattern-match).
      - `SliceWriteState` variants: `Written`, `SkippedNoSlice` (parse_two_slice produced None for this slice), `SkippedNoProjectName` (project resolver returned None), `IoError(String)`.
      - `RouteOutcome` struct: `live: SliceWriteState`, `project: SliceWriteState`.
      - New `route_two_slice_outcome(self_id, raw, kind, short_for_subject) -> RouteOutcome` — granular sibling of existing `route_two_slice`. The existing `route_two_slice` keeps its `Result<(), String>` signature for backward compat with current callers; the new `_outcome` variant is what `route_inbound_commune_body` consumes.
      - `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` for project resolution (cycle-3 HIGH 3) — NOT the old `derive_current_repo_names().first()` path at line 317. Falls back to existing resolution only when the new resolver returns None (for callers that lack a perch info.json).
      - `route_two_slice_outcome` calls return:
        - `project: SliceWriteState::SkippedNoSlice` when `parse_two_slice` finds no `<project-context>` tag.
        - `project: SliceWriteState::SkippedNoProjectName` when the resolver returns None.
        - `project: SliceWriteState::IoError(msg)` on filesystem write failure.
        - `project: SliceWriteState::Written` ONLY when the project file actually persisted (cycle-3 HIGH 1).
      - New `route_two_slice_with_precedence(self_id, raw, kind, short_for_subject, source) -> RouteOutcome` — sibling that ALSO applies precedence-metadata stale-overwrite mitigation. `source` is `"direct"` (inbound wrapper route) or `"llm"` (outbound echo_commune emit).
      - Precedence marker format (cycle-3 HIGH 2): `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n` as the FIRST line of every routed file (`live_context.md` and `projects/<name>/<self_id>.md`).
      - Marker rules:
        - On every write, prepend the marker line, then the slice body.
        - Before writing, READ the existing file. Parse any existing marker.
        - If new write source is `"direct"`: ALWAYS proceed (direct is source-of-truth). Update marker.
        - If new write source is `"llm"`: parse existing marker. If existing `source=direct` AND existing `routed_at` is within the protection window (default `PRECEDENCE_PROTECTION_SECS = 60`), REJECT the write and emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id} (existing source=direct routed_at={t}; protection window {N}s)`. Otherwise proceed.
        - If no marker present (file pre-dates the feature): treat as `source=llm routed_at=epoch`; new write proceeds.
      - `route_inbound_commune_body(self_id, body, source) -> RouteOutcome` extracted seam in `src/live/wrapper/mod.rs`. Calls `route_two_slice_with_precedence(self_id, body, "commune-inbound", &short, "direct")`. Unit-testable WITHOUT mocking `resume_session_with_exit`.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Outbound `run_echo_commune` (at echo_commune.rs:854 — verify line via grep) rewired to call `route_two_slice_with_precedence(..., "llm")` instead of `route_two_slice`.
      - Route failures from `route_inbound_commune_body` log a structured `[FILE-DROP]` WARN — non-fatal, LLM handoff continues.
      - "signoff" arm unchanged.
    </behavior>
    <action>
      Step 1 — Add the enums to `src/owl/echo_commune.rs` (near the existing `route_two_slice` definition at line 237):

      ```rust
      /// 25.3-04 Defect A (cycle-3 HIGH 1 fix): granular per-slice write outcome.
      /// Replaces the boolean conflation of `route_two_slice -> Result<(), String>`
      /// that caused logs/tests to report `project_routed: true` even when the
      /// project slice silently skipped (no project name resolved).
      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) enum SliceWriteState {
          /// Slice persisted to disk successfully.
          Written,
          /// `parse_two_slice` produced None for this slice (envelope absent).
          SkippedNoSlice,
          /// Project resolver returned None (no project for this self_id).
          /// Only meaningful for the `project` field; live slice never produces this.
          SkippedNoProjectName,
          /// IO error during write — carries the error message for logs.
          IoError(String),
      }

      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) struct RouteOutcome {
          pub(crate) live: SliceWriteState,
          pub(crate) project: SliceWriteState,
      }
      ```

      Step 2 — Add `route_two_slice_outcome` in `src/owl/echo_commune.rs` (cycle-3 HIGH 1 fix). Internally mirrors `route_two_slice` + `route_project_slot` but returns the granular outcome AND uses the Plan 01 shared resolver for project resolution:

      ```rust
      /// 25.3-04 cycle-3 HIGH 1 fix: granular-outcome variant of `route_two_slice`.
      /// Used by wrapper-inbound route + outbound run_echo_commune emit (via the
      /// precedence-wrapped sibling). Project resolution uses Plan 01's shared
      /// helper `crate::common::owlery::resolve_self_project_name_via_info_cwd`
      /// (cycle-3 HIGH 3 fix: single source of truth for project resolution).
      pub(crate) fn route_two_slice_outcome(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
      ) -> RouteOutcome {
          // ... implementation: replicate route_two_slice's live-slot logic, but
          // (a) return SliceWriteState::Written / SkippedNoSlice / IoError per slice,
          // (b) use crate::common::owlery::resolve_self_project_name_via_info_cwd
          //     for the project slice resolver (NOT derive_current_repo_names),
          // (c) return SliceWriteState::SkippedNoProjectName when resolver returns None.
      }
      ```

      Step 3 — Add `route_two_slice_with_precedence` in `src/owl/echo_commune.rs` (cycle-3 HIGH 2 fix). Wraps `route_two_slice_outcome` with precedence-metadata mitigation:

      ```rust
      const PRECEDENCE_PROTECTION_SECS: i64 = 60;
      const PRECEDENCE_MARKER_PREFIX: &str = "<!-- spt:source=";

      /// 25.3-04 cycle-3 HIGH 2 fix: precedence-metadata stale-overwrite mitigation.
      /// Replaces the cycle-2 strict-subset heuristic (which over-rejected legitimate
      /// summarization and under-rejected paraphrased rewrites).
      ///
      /// Marker format: `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n`
      /// prepended as the FIRST line of every routed file.
      ///
      /// Rules:
      ///   - source="direct" (inbound wrapper route): ALWAYS proceeds; marker updated.
      ///   - source="llm" (outbound echo_commune emit): if existing marker has
      ///     source=direct AND routed_at is within PRECEDENCE_PROTECTION_SECS,
      ///     REJECT + log `[ROUTE-GUARD] stale llm overwrite suppressed ...`.
      ///     Otherwise proceed (legitimate summarization wins).
      pub(crate) fn route_two_slice_with_precedence(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
          source: &str,  // "direct" or "llm"
      ) -> RouteOutcome { /* impl */ }

      /// Helper: parse precedence marker from existing file content.
      /// Returns (source, routed_at_iso) or None if no marker.
      fn parse_precedence_marker(content: &str) -> Option<(String, String)> { /* impl */ }

      /// Helper: prepend marker to body before write.
      fn prepend_precedence_marker(body: &str, source: &str) -> String {
          let now = crate::common::time::now_iso_utc();
          format!("<!-- spt:source={} spt:routed_at={} -->\n{}", source, now, body)
      }

      /// Helper: should `source="llm"` write be suppressed by existing direct marker?
      fn should_suppress_llm_write(existing_marker: Option<(String, String)>) -> bool {
          let (src, routed_at) = match existing_marker { Some(m) => m, None => return false };
          if src != "direct" { return false; }
          // Parse ISO8601 timestamp; compute delta to now.
          let routed_dt = match chrono::DateTime::parse_from_rfc3339(&routed_at) { Ok(d) => d, Err(_) => return false };
          let delta = chrono::Utc::now().signed_duration_since(routed_dt.with_timezone(&chrono::Utc));
          delta.num_seconds() < PRECEDENCE_PROTECTION_SECS
      }
      ```

      The implementation reads the existing file (BOTH live_context.md and project file as applicable), parses any marker line at the head, decides per-slice whether to suppress based on `source` + window, and dispatches to a marker-stripping variant of route_two_slice_outcome (or strips the marker before parsing slices and adds it back on write).

      Step 4 — Add the testable seam in `src/live/wrapper/mod.rs`:

      ```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 shared resolver from Plan 01 (`crate::common::owlery::
      /// resolve_self_project_name_via_info_cwd`), and routes each slice to disk
      /// via `route_two_slice_with_precedence` with `source="direct"` (precedence-
      /// metadata mitigation, cycle-3 HIGH 2). NO LLM handoff — caller responsibility.
      ///
      /// Returns granular `RouteOutcome` (cycle-3 HIGH 1) — never a boolean
      /// `project_routed` that lies when the project slice silently skipped.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,  // structured log tag, e.g. "commune-inbound"
      ) -> crate::owl::echo_commune::RouteOutcome {
          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: body is in DECODED form at this site (filesystem
          // read yields literal tags). NO decode pass needed.

          // Precedence-metadata source="direct": this is the wrapper-inbound route
          // and IS the source of truth at this moment. Always proceeds; updates marker.
          crate::owl::echo_commune::route_two_slice_with_precedence(
              self_id, body, source, &short, "direct",
          )
      }
      ```

      Step 5 — 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 precedence metadata on the OUTBOUND
      // echo_commune emit (route_two_slice_with_precedence with source="llm").
      if kind == "commune" {
          use crate::owl::echo_commune::SliceWriteState;
          let outcome = route_inbound_commune_body(&self.self_id, &body, "commune-inbound");
          // Cycle-3 HIGH 1 fix: log granular state, not boolean conflation.
          let live_str = match &outcome.live {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project)",  // unreachable for live; defensive
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          let project_str = match &outcome.project {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project-name)",
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          self.log(&format!(
              "[FILE-DROP] route_inbound_commune_body for {} (commune-inbound; live={}, project={})",
              self.self_id, live_str, project_str
          ));
          // Surface IO errors as WARN
          if matches!(outcome.live, SliceWriteState::IoError(_))
              || matches!(outcome.project, SliceWriteState::IoError(_))
          {
              self.log(&format!(
                  "[FILE-DROP] route failed for {} (LLM handoff continues): live={:?} project={:?}",
                  self.self_id, outcome.live, outcome.project
              ));
          }
      }
      ```

      Step 6 — Rewire outbound `run_echo_commune` in `src/owl/echo_commune.rs`. Find the existing `route_two_slice(...)` call at ~line 854 via grep:
      ```powershell
      Select-String -Path src/owl/echo_commune.rs -Pattern 'route_two_slice\(' -SimpleMatch
      ```
      Replace it with `route_two_slice_with_precedence(self_id, raw, kind, short, "llm")`. This applies the precedence-metadata mitigation to LLM-emit writes.

      Step 7 — Unit tests in `tests/native_owl.rs` targeting the seam (NO wrapper mocking — codex MEDIUM):

      Granular RouteOutcome tests (cycle-3 HIGH 1):
      - `route_inbound_commune_body_returns_written_for_both_slices_when_resolver_succeeds` — fake info.json + temp .git repo; body has both envelopes; assert `outcome.live == Written && outcome.project == Written`. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_project_name_when_resolver_returns_none` — info.json missing/cwd=psyche_dir → resolver None; body has `<project-context>`; assert `outcome.project == SkippedNoProjectName` AND no `projects/` write occurred. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_slice_when_envelope_absent` — body has only `<live-context>`; assert `outcome.project == SkippedNoSlice`. `#[serial]`.
      - `route_inbound_commune_body_returns_io_error_on_write_failure` — induce write failure (e.g. read-only dir or unwritable path); assert `outcome.project == IoError(_)`. `#[serial]`. (Skip on Windows if read-only-dir simulation is awkward; use a permission-injection helper if test scaffolds exist.)

      Precedence-metadata mitigation tests (cycle-3 HIGH 2):
      - `route_two_slice_with_precedence_direct_always_writes` — pre-populate project file with `<!-- spt:source=llm spt:routed_at=<old> -->\nllm-content`; call with `source="direct"`, new body `<project-context>direct-content</project-context>`; assert file now has `source=direct` marker + `direct-content`. `#[serial]`.
      - `route_two_slice_with_precedence_llm_suppressed_when_recent_direct_exists` — pre-populate with `source=direct` marker dated 10s ago; call with `source="llm"`; assert file UNCHANGED AND `[ROUTE-GUARD] stale llm overwrite suppressed` log emitted. `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_direct_marker_outside_window` — pre-populate with `source=direct` marker dated 120s ago (outside 60s default window); call with `source="llm"` + new body; assert file UPDATED with `source=llm` marker (legitimate summarization wins). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_existing_marker_is_llm` — pre-populate with `source=llm`; call with `source="llm"`; assert proceeds (LLM-over-LLM is normal cadence). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_no_existing_marker` — pre-populate file without marker (legacy file); call with `source="llm"`; assert proceeds with new marker prepended. `#[serial]`.
      - `route_two_slice_with_precedence_live_slice_unconditional` — verify the precedence guard applies to BOTH live + project slices uniformly (or document and test the per-slice policy explicitly). `#[serial]`.
      - `parse_precedence_marker_handles_malformed_marker_gracefully` — unit test of the parser helper.

      Helper test for the marker round-trip:
      - `precedence_marker_round_trip_preserves_body_bytes` — `prepend_precedence_marker` + strip → original body byte-equal.

      Symmetry test:
      - `route_inbound_commune_body_uses_shared_resolver` — assert (via behavior, e.g. setting up info.json that the OUTBOUND build_current_context_blocks resolves the same way) that wrapper + outbound + echo_commune all produce the same resolved project name for the same self_id. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_with_precedence 2>&1 | Select-Object -Last 30; cargo test --release precedence_marker 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`, `pub(crate)`.
      - `route_two_slice_outcome` returns granular per-slice state (cycle-3 HIGH 1).
      - `route_two_slice_with_precedence` applies precedence-metadata mitigation (cycle-3 HIGH 2).
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, returns `RouteOutcome`, unit-testable without LLM mocking.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff. Logs granular state (NOT boolean conflation).
      - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence(..., "llm")`.
      - Inbound wrapper-direct uses `route_two_slice_with_precedence(..., "direct")` (unconditional, source of truth).
      - Project resolution in `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` (cycle-3 HIGH 3 shared helper).
      - Route IO errors are structured WARN logs (operator-visible).
      - 4 granular-outcome tests + 6 precedence-mitigation tests + 1 marker round-trip + 1 symmetry test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: 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:

      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"
      ```
      Expected: first line is `<!-- spt:source=direct spt:routed_at=<ISO> -->`; subsequent lines contain `marker-25.3-04-Z`.

      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
      ```

      Step 6 — Precedence-metadata mitigation smoke (cycle-3 HIGH 2): wait for the next echo_commune fire (within 60s of the direct route) and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that would have erased the marker, the file contents are UNCHANGED and the `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line appears.
      - Wait further (>60s after the direct route) and trigger another echo_commune fire; this time the LLM emit proceeds (legitimate summarization wins after the protection window).

      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.
      - Precedence-metadata marker present at file head (`<!-- spt:source=direct ... -->`).
      - `[FILE-DROP] route_inbound_commune_body for todlando ...project=written` log line confirmed for the inbound.
      - Precedence-metadata mitigation verified: within protection window, marker preserved + `[ROUTE-GUARD] stale llm overwrite suppressed` log line appears. After window, LLM emit proceeds.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: 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 `outcome.live == SliceWriteState::Written` AND `outcome.project == SliceWriteState::Written` (cycle-3 HIGH 1 — no false-positive boolean).
      - Assert ALL FOUR invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO TRANSPORT-ENCODED ENVELOPE TAGS in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;EVENT`, `&lt;project-context`, `&lt;br&gt;`). (Defect C cycle-3 reframed 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.)
        (d) Precedence-metadata marker at file head — both files have first line matching `<!-- spt:source=direct spt:routed_at=.+ -->`. (Cycle-3 HIGH 2 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 with all cycle-3 mitigations active.
    </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 transport-encoded envelope tags, correct marker placement, AND precedence-metadata marker present.
      - `outcome.live == Written` AND `outcome.project == Written` (granular, cycle-3 HIGH 1).
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution + all cycle-3 mitigations:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers including context-sensitized legacy-perch (cycle-3 MEDIUM); shared resolver in `src/common/owlery.rs` (cycle-3 HIGH 3).
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 HIGH reframed entity invariant; dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing); static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; granular `RouteOutcome` enum (cycle-3 HIGH 1 — no false `project_routed`); precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2 — replaces strict-subset); shared resolver consumed from `src/common/owlery.rs` (cycle-3 HIGH 3); structured failure logs.
      - Cross-plan: phase-level invariant test (Task 4) jointly validates all four defects + cycle-3 mitigations.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk (with precedence marker at file head), with stale-overwrite protection against subsequent LLM drops within the 60s protection window.
    </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 3 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
         Expected: first line is `<!-- spt:source=direct spt:routed_at=... -->`; body contains `final-verify-marker-25.3`.
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
         ```
      4. Wait for next echo_commune fire within 60s; confirm marker preserved AND (if LLM emit drops the marker) `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line.
      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_with_precedence writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md` with precedence marker at file head. |
  | Precedence-metadata marker comparison | Reads existing on-disk marker; parses source + routed_at; compares against protection window. |

  ## 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 (shared helper from owlery.rs), never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice_outcome` 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 | PRECEDENCE-METADATA MITIGATION (cycle-3 HIGH 2): `route_two_slice_with_precedence` applies the marker-comparison guard on the OUTBOUND echo_commune emit path (`source="llm"`). If existing marker is `source=direct` within 60s, LLM write is SUPPRESSED + `[ROUTE-GUARD]` log surfaces. Inbound direct-route (`source="direct"`) remains unconditional (it IS the new source of truth). Replaces cycle-2 strict-subset heuristic (which over-rejected summarization). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to forge a `source=direct` marker | mitigate | The marker is APPLIED by SPT at write time — not read from envelope body. Adversarial body content containing `<!-- spt:source=direct ... -->` as user text is NOT trusted: SPT strips/replaces the head-marker on every write. If a body's first line LOOKS like a marker, it is treated as ordinary body content + a fresh SPT marker is prepended above it. Test: `precedence_marker_handles_user_body_with_fake_marker_at_head_gracefully`. |
  | T-25.3.04-T4 | Tampering | Marker timestamp manipulation (system clock skew or adversarial timestamp injection) | accept | Clock skew is a known systemic risk; the 60s window is generous. Adversarial timestamp injection is bounded by T-25.3.04-T3 (user body cannot supply markers). |
  | 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. Precedence-metadata 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 prefixes `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Granular `RouteOutcome` propagation (cycle-3 HIGH 1) ensures logs reflect actual per-slice state, not boolean conflations. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. (chrono is already in Cargo.toml for timestamp helpers.) |
  </threat_model>

  <verification>
  - Task 1: Plan 03 body-state-at-process_file_drop confirmed DECODED; shared helper presence in owlery.rs confirmed.
  - Task 2 implements Option 1 with granular RouteOutcome + precedence-metadata mitigation. Shared resolver consumed from owlery.rs.
  - Task 3 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 4 phase-level invariant test asserts joint resolution of all four defects + cycle-3 invariants (precedence marker present, granular outcomes).
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite) or existing route_two_slice tests.
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`; `route_inbound_commune_body` returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` exists with marker-comparison guard (cycle-3 HIGH 2).
  - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`; unit-testable without LLM mocking.
  - `process_file_drop` "commune" arm routes BEFORE LLM handoff; logs granular state.
  - Shared resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` consumed throughout (cycle-3 HIGH 3).
  - Outbound `run_echo_commune` uses `route_two_slice_with_precedence(..., "llm")`.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS with precedence-metadata marker at file head.
  - Stale-overwrite mitigation verified: LLM emit within 60s of direct route is SUPPRESSED; LLM emit after 60s proceeds.
  - Phase-level invariant test passes (joint resolution of all 4 defects + cycle-3 invariants).
  - All PowerShell — no Bash heredocs.
  - 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:
  - Plan 03 body-state-at-process_file_drop confirmation (DECODED per Option A LOCKED).
  - Shared-helper presence confirmation (`pub(crate) fn resolve_self_project_name_via_info_cwd` in owlery.rs).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call with granular-state log).
  - Diff summary for `src/owl/echo_commune.rs` (RouteOutcome + SliceWriteState enums + route_two_slice_outcome + route_two_slice_with_precedence; outbound rewire).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content showing precedence marker at head + `marker-25.3-04-Z`).
  - Precedence-metadata mitigation smoke verification (within-window suppression + outside-window proceed).
  - Phase-level invariant test pass evidence.
  - Codex cycle-2 HIGH/MEDIUM/LOW resolution table: each → task → verification.
    - HIGH 1 (false project_routed) → Task 2 enums + outcome propagation → granular-outcome tests.
    - HIGH 2 (monotonic guard underspecified) → Task 2 precedence-metadata variant → precedence-mitigation tests.
    - HIGH 3 (resolver helper placement) → resolved by Plan 01 cycle-3 placement → Task 1 grep gate.
    - MEDIUM (files_modified omits echo_commune.rs) → CORRECTED in manifest.
    - MEDIUM (file-path correction route_two_slice in echo_commune.rs) → CORRECTED throughout.
  - 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 persist synchronously with precedence-metadata stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>
  </content>
  </invoke>
  ---


  ## Review Instructions

  **Goal: convergence check.** For each cycle-2 HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then identify any NEW HIGH issues introduced by the cycle-3 replan.
  Use severity markers: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **CONVERGED** (no remaining HIGHs) OR **NOT CONVERGED** + list of blocking HIGHs.

first_user_message:
  # Cross-AI Plan Review Request (Cycle 3 — Final convergence check)

  These plans went through TWO prior cycles. Cycle 1 had 9 HIGHs (all resolved). Cycle 2 introduced 3 new HIGHs (claimed resolved in cycle 3). Verify convergence.

  ## Phase 25.3 — Roadmap
  ### 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 Reviews (Cycles 1 + 2)

  ---
  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**.

  ---

  # Cycle 2 Re-Review (post-replan)

  **Reviewed at**: 2026-05-22T18:10:00-07:00

  ## Prior HIGH Resolution

  | Prior HIGH | Status | Evidence |
  |---|---|---|
  | Plan 01: `resolve_self_project_stamp` could still fall back to cwd and emit `project="tracked"` | **RESOLVED** | Plan 01 now routes both stamp and context-block resolution through `info.json.cwd` plus a final cwd fallback guard. |
  | Plan 02: Option B exceeded file scope | **RESOLVED** | Plan 02 locks Option A no-name `<project-context>` and removes parser/writer changes from scope. |
  | Plan 02: “Psyche writes file” vs “Rust routes envelope” ambiguity | **RESOLVED** | Plan 02 explicitly standardizes on “SPT routes; you emit.” |
  | Plan 02: `<project-context name="...">` vs no-name inconsistency | **RESOLVED** | Option A no-name envelope is locked throughout. |
  | Plan 03: body decoding before EVENT framing parse would be unsafe | **RESOLVED** | Plan 03 now requires body decode only after framing/body extraction. |
  | Plan 03: missing dependency on envelope-shape decision | **RESOLVED** | Option A is locked, and Plan 03 now runs before Plan 02 with that assumption. |
  | Plan 04: named envelope routing ambiguity | **RESOLVED** | No-name Option A removes `name=` routing entirely. |
  | Plan 04: stale LLM re-emit could overwrite direct-routed content | **PARTIAL** | Monotonic guard is added, but the proposed strict-subset heuristic may reject legitimate summarization or still miss stale rewrites. |
  | Plan 04: Option 2 relies on prompt compliance | **RESOLVED** | Option 1 direct wrapper routing is locked/default; Option 2 is explicitly disfavored. |

  ## Fresh Review

  ### 25.3-01

  - **MEDIUM:** Plan 01 says `"tracked"` rejection is path-context-sensitive at all three layers, but the legacy perch field branch rejects `p == "tracked"` globally. That contradicts the stated invariant, even if today that field is usually unset.
  - **MEDIUM:** `resolve_self_project_name_via_info_cwd` appears introduced as a private helper in `src/owl/echo_commune.rs`, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it `pub(crate)` in a shared module or avoid depending on it outside `echo_commune.rs`.
  - **LOW:** Adding `serial_test` is pragmatic, but it expands the dependency surface for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not blocking.

  ### 25.3-02

  - **MEDIUM:** The static prompt test checks for absence of `<project-context name=` only in `psyche.md`, while the plan’s invariant says no name examples anywhere in psyche plus skill files. Add the same guard over `commune.md` and `signoff/SKILL.md`.
  - **MEDIUM:** The plan still depends on a human/LLM smoke cycle to prove artifact materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking verification, not just a deployment smoke.
  - **LOW:** Task numbering skips from Task 0 to Task 2. Not functionally harmful, but easy to confuse during execution.

  ### 25.3-03

  - **HIGH:** Plan 03 says the LLM stdin should have no `&amp;` entities in the end-to-end test. That is too strong if user content legitimately contains a literal encoded string like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not “no entities anywhere.”
  - **MEDIUM:** `event_body_unescape` uses `<br>` -> newline first, which means a user’s literal `<br>` text will not round-trip through `event_body_escape` because `<br>` is escaped as `&lt;br&gt;`, not `<br>`. The test named “literal_br” needs to distinguish transport newline markers from user literal `<br>`.
  - **MEDIUM:** The plan still includes a blocking decision between Option A and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If convergence is desired, lock Option A unless there is a real reason to keep the checkpoint.

  ### 25.3-04

  - **HIGH:** `route_inbound_commune_body` claims `project_routed: true` whenever `route_two_slice` returns `Ok`, but existing semantics may silently drop the project slice when no project resolves. That makes logs and tests falsely report project persistence. The outcome must reflect actual writes.
  - **HIGH:** The monotonic guard is underspecified for real context evolution. “Strict subset by nonblank lines” can reject valid summarization/compaction and can fail to catch stale rewrites that paraphrase or reorder. For a safety guard, prefer marker/version metadata, append-only merge, or explicit preserve-on-conflict behavior with tests for expected LLM summarization.
  - **MEDIUM:** Plan 04’s files_modified omits `src/owl/echo_commune.rs`, but Task 2 wires outbound echo_commune to `route_two_slice_monotonic` there.
  - **MEDIUM:** The helper placement/resolver dependency is muddy: Plan 04 says route via Plan 01’s resolver in `echo_commune.rs`, but wrapper should not depend on an owl module-private helper. Move the resolver to `common/owlery.rs` or `common/tracked.rs`.
  - **LOW:** The structural source-order test for “routes before LLM handoff” is brittle. A small dependency-injected function boundary would give a stronger behavioral test.

  **Overall risk after revision**: **HIGH**. Most Cycle 1 contract issues are resolved, but convergence is not reached because Plan 03’s entity invariant and Plan 04’s false route outcome plus weak stale-overwrite guard introduce remaining HIGH risks.

  ## Cycle-3 Plans

  ### 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/common/owlery.rs
    - src/owl/echo_commune.rs
    - Cargo.toml

  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs (cycle-2 HIGHs already resolved)
  #
  # Cycle-2 codex MEDIUMs addressed in this update:
  #   MEDIUM: "Plan 01 says 'tracked' rejection is path-context-sensitive at all three layers, but the legacy
  #     perch field branch rejects p == 'tracked' globally. That contradicts the stated invariant, even if
  #     today that field is usually unset." → Task 1 Step 3: legacy-perch branch now context-sensitizes the
  #     "tracked" rejection. It only rejects p == "tracked" when the SAME info.json's `cwd` path resolves to
  #     psyche_dir (mirrors layer-1 rule). A user with a real `tracked` project + a legacy perch field
  #     literally written "tracked" keeps that value. New test
  #     `resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` guards this.
  #   MEDIUM: "resolve_self_project_name_via_info_cwd appears introduced as a private helper in
  #     src/owl/echo_commune.rs, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it
  #     pub(crate) in a shared module or avoid depending on it outside echo_commune.rs." → Task 1 Step 3:
  #     `resolve_self_project_name_via_info_cwd` is now defined in `src/common/owlery.rs` as `pub(crate)`
  #     (NOT in echo_commune.rs). `resolve_self_project_stamp` and `build_current_context_blocks`
  #     consume it via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Plan 04's wrapper
  #     route uses the SAME path. Single source of truth.
  #   LOW (accepted, not addressed): "Adding serial_test is pragmatic, but it expands the dependency surface
  #     for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not
  #     blocking." → Kept `serial_test` as a `[dev-dependencies]` entry. Trade-off documented: serial_test is
  #     dev-only, widely-used, and the local-mutex alternative is non-blocking. If reviewer escalates, the
  #     local Mutex<()> swap is a single-commit change.
  #
  # Cycle-2 HIGHs (already resolved in cycle 2 — do NOT regress):
  #   - info.json-cwd-first resolver chain (locked decision #2).
  #   - belt-and-braces "tracked" filter at all three resolver layers (path-context-sensitive).
  #   - `(name=<X>)` debug-visible token in CURRENT_PROJECT_CONTEXT header (smoke verification).
  #   - `find_git_root_basename` made `pub(crate)`.
  #   - tests/native_owl.rs removed from manifest; tests live in inline `#[cfg(test)] mod tests` blocks.

  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 → legacy perch `project` field → 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 — INCLUDING when a legacy perch `project` field happens to contain the literal string `tracked` for a real `tracked` repo (cycle-3 MEDIUM fix)."
      - "`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)."
      - "The new helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` so Plan 04's wrapper route + outbound EVENT stamp can consume the SAME helper — single source of truth (cycle-3 MEDIUM fix)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper + new `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` (reads Self's perch info.json `cwd` field, passes Path to `project_name_from_cwd_path`). Moved here from echo_commune.rs per cycle-3 MEDIUM."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Refactored `build_current_context_blocks` + `resolve_self_project_stamp` consuming the shared resolver via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Belt-and-braces `tracked` rejection at all three layers including context-sensitized legacy-perch branch."
        contains: "resolve_self_project_name_via_info_cwd"
      - 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::resolve_self_project_name_via_info_cwd"
        via: "shared helper reads Self's perch info.json cwd → project_name_from_cwd_path"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "same shared helper — symmetry guarantee with build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/live/wrapper/mod.rs (Plan 04 consumer)"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "Plan 04's wrapper route_inbound_commune_body consumes the SAME helper"
        pattern: "resolve_self_project_name_via_info_cwd"
      - 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 `project` 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 AT EVERY LAYER (cycle-3 MEDIUM fix extends path-context-sensitivity to the legacy-perch branch).

  3. **Cycle-3 MEDIUM (single source of truth):** The resolver helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)`, NOT in `src/owl/echo_commune.rs`. Plan 04's wrapper route_inbound_commune_body consumes the SAME helper from the SAME location. No cross-module private-helper coupling.

  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)` or `resolve_self_project_name_via_info_cwd(&str)` already exist in `src/common/owlery.rs`. From the grep audit (planner side), no such helpers exist — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)`. Task 0 explicitly audits + reports findings before Task 1 creates the helpers.

  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) -> Option<String>` AND `resolve_self_project_name_via_info_cwd(&str) -> Option<String>` in `src/common/owlery.rs` (both `pub(crate)` so wrapper + echo_commune share). Refactors `resolve_self_project_stamp` to consume the new resolver. Adds belt-and-braces path-context-sensitive `"tracked"` rejection at ALL THREE layers including the legacy-perch branch.
  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` + `resolve_self_project_name_via_info_cwd` helpers (both `pub(crate)`); `find_git_root_basename` exposed as `pub(crate)` if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new shared resolver.
  - `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|fn resolve_self_project' -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 helpers around it.
      - If nothing exists → Task 1 creates both helpers from scratch in `src/common/owlery.rs`.

      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` AND `resolve_self_project_name_via_info_cwd` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd|fn .*via_info_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 + resolve_self_project_name_via_info_cwd helpers in owlery.rs + 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_name_via_info_cwd("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns `Some("claude_skill_owl")`.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json is missing returns `None` gracefully.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json `cwd` field is None returns `None` (no fallback in this layer — caller decides next-layer behavior).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/.../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` is None → falls through to legacy perch `project` field. Path-context-sensitive `tracked` rejection at legacy-perch branch: only reject `p == "tracked"` when the SAME info.json's cwd resolves to psyche_dir. If cwd is unavailable, accept the legacy value (could be a real `tracked` repo).
      - `resolve_self_project_stamp("todlando")` legacy-perch path with `project = "tracked"` AND info.json cwd path resolves to a real `tracked/` repo → returns `project = "tracked"` (cycle-3 MEDIUM fix — non-regression).
      - `resolve_self_project_stamp("todlando")` final fallback layer: process cwd is psyche_dir → `stamp.project` cleared (NOT `tracked`).
      - Real-project-named-tracked at final fallback: process cwd is a real `tracked/` repo OUTSIDE psyche_dir → `stamp.project == "tracked"` preserved.
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version) 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).

      Step 3 — Add TWO new helpers in `src/common/owlery.rs` immediately after `find_git_root_basename`. BOTH are `pub(crate)` so wrapper (Plan 04) + echo_commune share the single source of truth:

      First helper — path-based resolver:
      - Name: `project_name_from_cwd_path(path: &std::path::Path) -> Option<String>`
      - Resolution order (mirrors `derive_current_repo_names()` (a)+(c)):
        (a) `find_git_root_basename(path)` — walk parents for `.git`, return that dir's `file_name()`.
        (b) Fallback to `path.file_name()` (D-06 fallback equivalent).
        (c) Path-context-sensitive `tracked` rejection: if resolved name is `"tracked"` AND `path` (canonicalized best-effort) equals or is within `psyche_dir()`, return None. Otherwise pass the name through (real-project-named-tracked non-regression).
      - Returns None if `path` has no file_name component (filesystem root).
      - Doc-comment cites 25.3-01 Defect B2 + locked decision #2 + cycle-3 MEDIUM (single source of truth, used by Plan 04 wrapper too).

      Helper internal: `fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool` — best-effort canonicalize both `path` and `psyche_dir()`, return `path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)`.

      Second helper — perch-info-driven resolver:
      - Name: `resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>`
      - Behavior: read `info_file(self_id)`, parse `InfoJson`, take `cwd` field (Option<String>), pass to `project_name_from_cwd_path`. Return None on any IO/parse/None-field failure (callers handle the layer-2 fallback).
      - Doc-comment: "Plan 04 wrapper route + Plan 01 echo_commune outbound EVENT stamp + build_current_context_blocks ALL consume this helper. Single source of truth (cycle-3 MEDIUM)."

      Step 4 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127). The refactor consumes the new shared helper from `crate::common::owlery::resolve_self_project_name_via_info_cwd` (NOT a private helper inside echo_commune.rs — cycle-3 MEDIUM).

      Resolver chain (top to bottom):
      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, set `s.project = name` and return.
      2. **Layer 2 (legacy perch `project` field — context-sensitized):** call `read_self_project_from_info(self_id)`. If `Some(p)` and `!p.is_empty()`:
         - If `p == "tracked"`: read the SAME perch info.json's `cwd` field (re-parse — or refactor `read_self_project_from_info` to return both fields). If the cwd path resolves via `project_name_from_cwd_path` to `None` (i.e. the cwd IS psyche_dir), reject — fall through to layer 3. Otherwise accept `p` (real `tracked` repo non-regression).
         - If `p != "tracked"`: accept unconditionally.
         - Set `s.project = p` and return.
      3. **Layer 3 (final cwd-stamp fallback):** `s = crate::common::git::stamp()`. If `s.project == "tracked"`, read process cwd via `std::env::current_dir()`. If `project_name_from_cwd_path(&cwd)` returns `None`, clear `s.project = String::new()` (the D-06 false positive). Otherwise leave `s.project` as-is (real `tracked` repo).

      Helper changes:
      - If `read_self_project_from_info` currently returns only `Option<String>`, EITHER extend it to also return cwd OR add a sibling helper `read_self_perch_info(self_id: &str) -> Option<InfoJson>` (preferred — returns the parsed struct so layer 2 can peek cwd). Document choice in SUMMARY.

      Remove the TODO comment at echo_commune.rs:108-109 (now fulfilled).

      Step 5 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s):
      - `owlery.rs` tests live in that file's existing `mod tests`.
      - `echo_commune.rs` tests live in one of the existing blocks (line 1127, 1703, 1894, 2081 — choose the resolver-shaped block; if no fit, use line 1127).

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

      Tests (10 total):
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — 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).
      - `owlery::tests::resolve_self_project_name_via_info_cwd_reads_cwd_field` — write fake perch info.json with cwd → temp .git repo; assert helper returns Some(repo_basename). `#[serial]`.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_info_missing` — assert no panic, returns None.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_cwd_field_none` — assert no panic, returns None.
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write fake perch info.json with cwd → temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` — info.json carries `project = "tracked"` AND `cwd` pointing to a real `tracked/` repo OUTSIDE psyche_dir; assert stamp.project == "tracked" (cycle-3 MEDIUM regression guard). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_rejects_tracked_when_cwd_is_psyche` — info.json carries `project = "tracked"` AND `cwd` pointing to psyche_dir; assert layer-2 rejected, falls through to layer 3. `#[serial]`.
      - `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; cargo test --release resolve_self_project 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, `pub(crate)`.
      - `resolve_self_project_name_via_info_cwd` exists in `src/common/owlery.rs`, `pub(crate)` (cycle-3 MEDIUM: single source of truth, NOT in echo_commune.rs).
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` consumes `crate::common::owlery::resolve_self_project_name_via_info_cwd` at layer 1.
      - All three resolver layers filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces; layer 2 now context-sensitized per cycle-3 MEDIUM).
      - Real-project-named-tracked tests pass at BOTH layer 2 and layer 3 (regression guards).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - All ~10 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 shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 a layered resolver chain (consuming the SHARED helper from `crate::common::owlery`):

      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, use it.
      2. **Layer 2 (process-cwd fallback for callers like signoff prompt-build that may run from a real project cwd):** if layer 1 returns None, call `owlery::derive_current_repo_names().into_iter().next()`. If that returns `"tracked"`, verify via `project_name_from_cwd_path(&std::env::current_dir()?)` — if the helper returns None (psyche_dir false positive), drop. Otherwise accept (real `tracked` repo).
      3. **Result:** `Option<String>` resolved project name. If None, emit NO `CURRENT_PROJECT_CONTEXT` block.

      Output format when project resolves:
      `\n\nCURRENT_PROJECT_CONTEXT (name=<X>):\n<body>`
      where `<body>` is `std::fs::read_to_string(owlery::project_worktree_path(&name).join(format!("{}.md", self_id)))` or the literal `"(none — first commune in project)"` on read failure.

      The `(name=<X>)` parenthetical is the debug/test-visible token codex MEDIUM requested for smoke verification.

      Update the doc comment on `build_current_context_blocks` (lines 372-397):
      - Cite Defect B2 fix and the new resolver order (info-cwd via shared helper → 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`) AND Plan 04 wrapper route_inbound_commune_body consume the SAME shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 → 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 `crate::common::owlery::resolve_self_project_name_via_info_cwd` first (shared helper, NOT a private function).
      - 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 + cycle-3 single-source-of-truth note.
    </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|resolve_self_project_name_via_info_cwd' -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.
      - Plan 04 wrapper consumer (NOT yet present — will appear in Plan 04). Confirm the helper signature is wrapper-friendly (pub(crate), no echo_commune-private dependencies).
      - 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), 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 — Confirm `resolve_self_project_name_via_info_cwd` visibility is `pub(crate)` (not `fn`-private). This is the cycle-3 MEDIUM gate for Plan 04 reuse.

      Step 5 — 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 6 — 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."

      Step 7 — Write a one-line "Plan 04 handoff note": "Plan 04's wrapper route MUST consume `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` — same shared helper as build_current_context_blocks + resolve_self_project_stamp. NO duplicated resolver logic in wrapper."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5; Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' | Measure-Object | Select-Object -ExpandProperty Count</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.
      - `resolve_self_project_name_via_info_cwd` visibility verified as `pub(crate)` (Plan 04 reuse gate).
      - Plan 02 handoff note + Plan 04 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 `resolve_self_project_name_via_info_cwd_returns_none_when_info_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 at ALL THREE layers (cycle-3 MEDIUM extension): filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit tests at each layer guard 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). Pin to major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW in owlery.rs) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW in owlery.rs, `pub(crate)`) is the perch-info-driven entry point — SHARED by Plan 04.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback, now context-sensitized for `tracked`.
     - `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: `build_current_context_blocks`, `derive_current_repo_names`, `resolve_self_project_stamp`, and `resolve_self_project_name_via_info_cwd` callers documented.

  3. `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 04 reuse gate).

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

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

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

  <success_criteria>
  - `project_name_from_cwd_path(path)` AND `resolve_self_project_name_via_info_cwd(self_id)` both exist in `src/common/owlery.rs` as `pub(crate)`.
  - `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, INCLUDING context-sensitized legacy-perch branch (cycle-3 MEDIUM); real-project-named-tracked non-regression preserved at every layer.
  - `cargo test --release` passes including all 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 [context-sensitized] → cwd-stamp), each with its `tracked` filter point.
  - Confirmation that `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` (NOT in echo_commune.rs).
  - Caller audit table for `build_current_context_blocks`, `derive_current_repo_names`, and `resolve_self_project_name_via_info_cwd`.
  - Codex review resolution table: each cycle-2 MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note (at BOTH layer 2 and layer 3).
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Plan 04 handoff note (wrapper consumes the shared helper from owlery.rs).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>
  </content>
  </invoke>
  ---

  ### 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
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (static prompt-contract test scope): "The static prompt test checks for absence of
  #     `<project-context name=` only in psyche.md, while the plan's invariant says no name examples anywhere
  #     in psyche plus skill files. Add the same guard over commune.md and signoff/SKILL.md." → Task 3 Step 4
  #     extends the static prompt-contract test to ALSO assert no `<project-context name=` substring exists
  #     in `plugin/spt/skills/commune/commune.md` AND `plugin/spt/skills/signoff/SKILL.md`. The test now reads
  #     three files and asserts the no-name guard against each.
  #   MEDIUM (smoke verification rigor): "The plan still depends on a human/LLM smoke cycle to prove artifact
  #     materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking
  #     verification, not just a deployment smoke." → Task 5 already uses `checkpoint:human-verify gate=
  #     "blocking"` (cycle 2 was correct here). Reinforced in `<done>` block: operator-verify is a hard gate;
  #     a `Test-Path` false MUST be reported as a failure, not a soft warning.
  #   LOW (task numbering gap): "Task numbering skips from Task 0 to Task 2." → Task numbers normalized:
  #     Task 1 (Read Plan 01 + Plan 03 SUMMARYs), Task 2 (psyche.md edits), Task 3 (skill files),
  #     Task 4 (deploy + smoke), Task 5 (human-verify checkpoint). Sequential. No gaps.
  #
  # Cycle-2 HIGHs preserved (already resolved):
  #   - Option A locked (no-name `<project-context>` shape).
  #   - "SPT routes; you emit" phrasing.
  #   - Plan 03 encoding-contract reference (now unambiguously DECODED per cycle-3 Plan 03 lock).
  #   - depends_on: [25.3-01, 25.3-03].
  #
  # Plan 03 handoff update (cycle-3): Plan 03 LOCKED Option A. Body state at LLM-stdin = DECODED. psyche.md
  # absorption rule 5 (Encoding assumption) now states this literally: "At the LLM-stdin boundary, the EVENT
  # envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT
  # re-encode."

  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 DECODED form (Plan 03 LOCKED Option A) — so future editors can't accidentally re-encode."
      - "Static prompt-contract test (cycle-3 MEDIUM extension) asserts NO `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md — the no-name guard covers ALL three files."
    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"
      - path: "tests/native_owl.rs"
        provides: "Static prompt-contract test (cycle-3 extension) asserts no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md."
        contains: "psyche_md_contains_25_3_absorption_section"
    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: DECODED at LLM-stdin per Option A LOCKED)"
        via: "body-state-at-LLM-stdin = DECODED literally"
        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 03 LOCKED (cycle-3):** body state at LLM-stdin = DECODED. psyche.md absorption rule 5 states this literally. No more checkpoint-dependent placeholder.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (DECODED at LLM-stdin); this plan's absorption rules reference that state 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 (DECODED at LLM-stdin).
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - `tests/native_owl.rs` — static prompt-contract test extended (cycle-3) to assert no `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md.
  - 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 (DECODED at LLM-stdin per Option A LOCKED).

  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; cycle-3 LOCKED Option A):
  - Body state at LLM stdin = DECODED. psyche.md absorption rule 5 references this literally.

  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 1: 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 is DECODED (cycle-3 LOCKED Option A). psyche.md absorption rule 5 will state this literally: "At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT re-encode."

      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 (`CURRENT_PROJECT_CONTEXT (name=<X>):`).
      - Plan 03 body-state-at-LLM-stdin extracted (DECODED — locked Option A).
      - 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, tests/native_owl.rs</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 Plan 03 LOCKED Option A).** At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags (you see `<`, not `&lt;`). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume. User-prose entities inside body content (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched — they are character content, not transport encoding (cycle-3 reframed entity invariant).

      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 (cycle-2 MEDIUM + cycle-3 MEDIUM extension). Add a test in `tests/native_owl.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 in psyche.md");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("DECODED") || content.contains("encoding contract"),
                  "must reference Plan 03 contract (DECODED at LLM-stdin)");
      }

      /// Cycle-3 MEDIUM: extend the Option-A no-name guard to commune.md + signoff/SKILL.md.
      /// The codex review noted that the cycle-2 test only covered psyche.md; the plan's
      /// invariant says ALL three files. This test fills that gap.
      #[test]
      fn skill_files_have_no_project_context_name_attr() {
          let commune = std::fs::read_to_string("plugin/spt/skills/commune/commune.md")
              .expect("commune.md must exist");
          let signoff = std::fs::read_to_string("plugin/spt/skills/signoff/SKILL.md")
              .expect("signoff/SKILL.md must exist");
          assert!(!commune.contains("<project-context name="),
                  "Option A locked: no name= attr in commune.md (cycle-3 MEDIUM)");
          assert!(!signoff.contains("<project-context name="),
                  "Option A locked: no name= attr in signoff/SKILL.md (cycle-3 MEDIUM)");
      }
      ```

      These are static prompt-contract tests — not full LLM-output goldens, but they guard against accidental phrase drift in future edits across ALL three documents.
    </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; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists in psyche.md 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 rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - TWO static prompt-contract tests pass: one for psyche.md, one for commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
    </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.

      Self-verification: after edits, the `skill_files_have_no_project_context_name_attr` test from Task 2 MUST pass against these freshly-edited files. Run it as a self-check before considering this task done.
    </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 '<project-context name=' -SimpleMatch | Measure-Object | Select-Object -ExpandProperty Count; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 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.
      - `skill_files_have_no_project_context_name_attr` test passes (cycle-3 MEDIUM gate).
    </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 (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 DECODED state per Plan 03 Option A LOCKED. Apply the cycle-3 reframed invariant: NO `&lt;EVENT`, `&lt;/EVENT`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;` substrings in the body. User-prose entities (if any in todlando's body) pass through.
    </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 DECODED state (no forbidden transport-encoded envelope-tag substrings).
    </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 (cycle-3 MEDIUM: layer-2 legacy-perch field context-sensitized); shared helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs`.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 reframed entity invariant (no transport-encoded envelope tags at LLM-stdin); dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension); 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 add wrapper-direct routing with explicit RouteOutcome + precedence-metadata stale-overwrite mitigation).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`). HARD GATE — false means failure (cycle-3 MEDIUM: this is a real blocking verification, not a soft warning).
      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;`) — cycle-3 reframed invariant verification (no transport-encoded envelope-tag substrings).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform (no transport-encoded envelope tags), 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 tests assert no `<project-context name=` substring exists in psyche.md AND commune.md AND signoff/SKILL.md (cycle-3 MEDIUM extension). |
  | 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. HARD GATE.
  - `live_context.md` for todlando continues to update normally.
  - TWO static prompt-contract tests pass (psyche.md + skill files — cycle-3 MEDIUM extension).
  - Encoding-contract smoke confirms DECODED state at LLM-stdin (cycle-3 reframed invariant).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing; rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
  - 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 (HARD GATE).
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract tests assert no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
  </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 + DECODED 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.
  - Static prompt-contract test pass evidence (both tests).
  - Codex review resolution table: each cycle-2 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 adds wrapper-direct routing with explicit RouteOutcome (no false project_routed) and precedence-metadata stale-overwrite mitigation."
  </output>
  </content>
  </invoke>
  ---

  ### 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: true
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGH + MEDIUMs
  #
  # Cycle-2 codex HIGH addressed:
  #   HIGH (entity invariant too strong): "Plan 03 says the LLM stdin should have no `&amp;` entities in the
  #     end-to-end test. That is too strong if user content legitimately contains a literal encoded string
  #     like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not
  #     'no entities anywhere.'" → Task 3 Step 6 REFRAMED. The end-to-end invariant now asserts NO TRANSPORT-
  #     ENCODED ENVELOPE TAGS at LLM-stdin. The forbidden substring set is EXPLICIT:
  #       `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`,
  #       `&lt;project-context`, `&lt;/project-context`,
  #       `&lt;live-context`, `&lt;/live-context`,
  #       `&lt;br&gt;`
  #     User-content entities NOT forming envelope tags (e.g. user prose containing `&amp; rules!`, `&lt; 5`,
  #     or a literal HTML snippet `&amp;copy;`) MUST pass through untouched. The test fabricates both a body
  #     with envelope tags AND a body with user-prose entities; asserts forbidden tags absent AND user prose
  #     present byte-identical.
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (event_body_unescape `<br>` round-trip framing): "event_body_unescape uses `<br>` → newline first,
  #     which means a user's literal `<br>` text will not round-trip through event_body_escape because `<br>`
  #     is escaped as `&lt;br&gt;`, not `<br>`. The test named 'literal_br' needs to distinguish transport
  #     newline markers from user literal `<br>`." → Traced manually: the existing encode order DOES preserve
  #     round-trip. Input `"<br>"` → escape:`& < > " \n→<br>` order → `"&lt;br&gt;"` (the `<` is escaped
  #     BEFORE the `\n→<br>` step ever sees a `<br>` to insert). Decode order: `<br>→\n` FIRST sees no `<br>`
  #     in `"&lt;br&gt;"`; then `&lt;→<` → `"<br>"`. Round-trip CORRECT.
  #     Input `"\n"` → escape → `"<br>"` → decode: `<br>→\n` → `"\n"`. ALSO CORRECT.
  #     So the cycle-2 concern was about TEST CLARITY, not a real round-trip break. Task 3 Step 4 now has
  #     TWO clearly-named tests covering both cases:
  #       - `event_body_round_trip_preserves_user_literal_br_text` (input contains literal `<br>` characters
  #         as user prose)
  #       - `event_body_round_trip_decodes_transport_br_to_newline` (input contains a literal `\n` character
  #         that gets transport-encoded as `<br>` and must decode back to `\n`)
  #     The test asserts byte-equality across both round-trips. The encode/decode order is locked AS-IS
  #     (amp-first encode + amp-last decode + `<br>` first in decode + `\n→<br>` last in encode).
  #   MEDIUM (Option A/B checkpoint redundant): "The plan still includes a blocking decision between Option A
  #     and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If
  #     convergence is desired, lock Option A unless there is a real reason to keep the checkpoint." → REMOVED
  #     the checkpoint:decision task. Option A (decode at wrapper LLM-stdin boundary) is LOCKED. Task 2 is
  #     gone. Plan 03 now has only Tasks 1, 3, 4. The freed slot is unused (Plan 03 stays at 3 tasks; the
  #     end-to-end invariant + chunk-boundary tests merged into Task 3 Steps 5-6 which were already there).
  #     Plan 04's body-state-at-process_file_drop is now an UNAMBIGUOUS handoff: DECODED (filesystem read of
  #     user-written commune file yields literal tags; no decode needed in Plan 04 itself).
  #
  # Cycle-2 LOW addressed:
  #   LOW: "Large contract doc in poll.rs may age poorly." → Task 3 Step 3 keeps the 80-line extraction
  #     threshold. Decision deterministic.
  #
  # Cycle-2 strengths preserved:
  #   - Decode happens AFTER EVENT framing parse (NOT global string unescape). Documented + tested.
  #   - Reframed invariant: "one body decode per transport traversal" (NOT "one site only").
  #   - EVENT-PART chunk-boundary tests (Task 3 Step 5).
  #   - PowerShell-only commands (no Bash heredocs).

  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 contains NO TRANSPORT-ENCODED ENVELOPE TAGS — the forbidden substrings are: `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;`. User-prose entities NOT forming envelope tags (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched."
      - "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)."
      - "`<br>` round-trip: user-prose literal `<br>` text and transport newline-as-`<br>` are BOTH handled correctly by the current encode order (`& < > \" \\n→<br>`) + decode order (`<br>→\\n`, `&lt;`, `&gt;`, `&quot;`, `&amp;`). Two distinct tests prove this (cycle-3 MEDIUM)."
      - "Option A is LOCKED: decode happens at the wrapper LLM-stdin boundary AFTER body extraction. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04)."
    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 TRANSPORT-ENCODED ENVELOPE TAGS in final body; user-prose entities pass through; 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:
  - **Option A is LOCKED (cycle-3):** decode happens at the wrapper LLM-stdin boundary AFTER body extraction. NO checkpoint:decision in this plan — the choice is settled. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written commune file yields literal tags).
  - **Plan reorder:** This plan executes BEFORE Plan 02. Plan 02's psyche.md absorption rules reference the locked Option A.
  - **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.
  - **Cycle-3 HIGH (entity invariant reframe):** the end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched.
  - **Cycle-3 MEDIUM (`<br>` round-trip clarity):** TWO distinct tests cover (a) user-prose literal `<br>` text and (b) transport newline-as-`<br>` — both round-trip correctly under the existing encode order.

  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 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the wrapper LLM-stdin boundary (Option A LOCKED); remove any double-encodes. Writes the contract doc. Adds tests including the cycle-3 reframed entity invariant + the two-distinct-`<br>` tests + EVENT-PART chunk-boundary regressions.
  3. (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. With Option A locked, `process_file_drop` sees DECODED body — the wrapper's filesystem read of the user-written commune file yields literal tags directly. No decode pass needed in Plan 04 itself. Task 4 explicitly states this in the Plan 04 handoff note.
  </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:685-739. -->

  ```rust
  // src/owl/poll.rs:685 (encode order — amp first, \n→<br> LAST)
  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 (amp-last invariant).
  ```

  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. Option A LOCKED: site will live at the wrapper LLM-stdin boundary AFTER body extraction.

  Round-trip trace (cycle-3 MEDIUM `<br>` clarity):
  - Input `"\n"` → escape (amp-first, `\n→<br>` LAST) → `"<br>"` → decode (`<br>→\n` FIRST, then `&lt;` etc.) → `"\n"`. ✓
  - Input `"<br>"` (user prose) → escape: `&` `<` `>` `"` `\n` order → after `<` step: `"&lt;br&gt;"` → `\n` step: no `\n` so unchanged → `"&lt;br&gt;"` → decode: `<br>→\n` finds no `<br>` in `"&lt;br&gt;"` → `&lt;→<` → `"<br>"`. ✓
  - Both round-trips byte-equal. The existing encode/decode order is correct AS-IS.
  </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="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site at wrapper LLM-stdin boundary (Option A LOCKED), write contract doc, add tests including cycle-3 reframed entity invariant + dual-`<br>` tests + 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. Option A LOCKED: body decode happens at the wrapper LLM-stdin boundary AFTER body extraction.
      - 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 wrapper LLM-stdin 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 ALL of: bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>` (user prose), literal `\n` (transport newline).
      - 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).
      - Cycle-3 reframed entity invariant: end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched (byte-equality).
      - Cycle-3 dual-`<br>` tests: BOTH `user-prose literal <br>` round-trip AND `transport \n encoded as <br>` round-trip are byte-equal.
      - 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.
      ///
      /// Round-trip cases (cycle-3 MEDIUM):
      /// - Input `"\n"` → escape → `"<br>"` → unescape → `"\n"`. ✓
      /// - Input `"<br>"` (user prose) → escape → `"&lt;br&gt;"` → unescape → `"<br>"`. ✓
      /// Both byte-equal under the current encode order (`& < > " \n→<br>` LAST).
      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 per Option A LOCKED. 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 wrapper LLM-stdin boundary (`resume_session_with_exit` call site) — 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 + Option A locked + 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 (wrapper LLM-stdin boundary AFTER body extraction; amp-last invariant; Option A LOCKED).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see: ENCODED bodies throughout (Option A).
      - Plan 04 process_file_drop body state: DECODED (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04).
      - 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`:
      - `event_body_unescape_inverts_event_body_escape_for_html_content` — body with `<`, `>`, `"`, `&`.
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content` — body starting with `&amp;` (amp-last invariant guard).
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags` — body containing `<live-context>...</live-context>`.
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` — body containing `</EVENT>` — must NOT reframe.
      - **Cycle-3 MEDIUM dual-`<br>` tests** (TWO distinct cases):
        - `event_body_round_trip_preserves_user_literal_br_text` — input is user prose containing literal `<br>` text (e.g. `"line one<br>line two"` where `<br>` is meant as character content, NOT a newline marker); after escape→unescape, the literal `<br>` characters round-trip byte-equal.
        - `event_body_round_trip_decodes_transport_br_to_newline` — input contains a literal `\n` character; after escape, body contains `<br>` (the transport marker); after unescape, the `<br>` decodes back to `\n` byte-equal.
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` — covers the asymmetric site directly.

      Step 5 — EVENT-PART chunk-boundary tests (cycle-2 MEDIUM, preserved):
      - `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`) — **CYCLE-3 HIGH REFRAMED INVARIANT**:

      Build TWO body scenarios:

      **Scenario A (envelope-tag body):** Fabricate a commune body containing literal `<live-context>marker-live-aaa</live-context>` AND literal `<project-context>marker-proj-bbb</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 NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin. The EXPLICIT FORBIDDEN SUBSTRING SET (test must check every one):
      ```rust
      let forbidden = [
          "&lt;EVENT",
          "&lt;/EVENT",
          "&lt;EVENT-PART",
          "&lt;/EVENT-PART",
          "&lt;project-context",
          "&lt;/project-context",
          "&lt;live-context",
          "&lt;/live-context",
          "&lt;br&gt;",
      ];
      for tag in forbidden {
          assert!(!final_body.contains(tag),
                  "LLM stdin must not contain transport-encoded envelope tag `{}` (cycle-3 reframed invariant)", tag);
      }
      ```

      Additionally assert that inner envelope tags ARE present as LITERAL `<` / `>`:
      ```rust
      assert!(final_body.contains("<live-context>marker-live-aaa</live-context>"));
      assert!(final_body.contains("<project-context>marker-proj-bbb</project-context>"));
      ```

      **Scenario B (user-prose entity body):** Fabricate a commune body where the user prose contains literal entity-shaped strings that are NOT envelope tags:
      - `"This rule about &amp; in URLs is documented."`
      - `"Use &lt; 5 in math notation."`
      - `"HTML entity for copyright is &amp;copy;."`

      Run through the same pipeline. Assert these byte-identical strings are PRESENT in the final body at LLM-stdin (user prose passes through untouched — cycle-3 HIGH reframe):
      ```rust
      assert!(final_body.contains("This rule about &amp; in URLs is documented."),
              "user-prose entity must pass through untouched (cycle-3 HIGH reframe)");
      assert!(final_body.contains("Use &lt; 5 in math notation."));
      assert!(final_body.contains("HTML entity for copyright is &amp;copy;."));
      ```

      Combined assertion logic: the forbidden-substring set is checked AGAINST envelope-tag patterns ONLY (the `&lt;EVENT`, `&lt;br&gt;`, etc.). User prose containing standalone `&amp;` or `&lt; 5` (with a space-separator that prevents tag formation) passes through.

      Document this distinction in the test's doc comment:
      > Cycle-3 HIGH reframe: the invariant is "no transport-encoded ENVELOPE TAGS at LLM-stdin", NOT "no entities at LLM-stdin". User prose may legitimately contain `&amp;`, `&lt;`, `&gt;`, `&quot;` as character content; those round-trip through the transport because the encode/decode pair is symmetric AND those characters never form structural envelope tokens by themselves.
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30; cargo test --release event_body_round_trip 2>&1 | Select-Object -Last 30; cargo test --release event_part_chunk_boundary 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 per Option A LOCKED.
      - 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."
      - 4 base unit tests + 2 dual-`<br>` tests (cycle-3 MEDIUM) + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + Scenario B) pass.
      - Cycle-3 HIGH reframed invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
      - 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).

      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 transport-encoded envelope tags (cycle-3 reframed invariant)
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY: "Option A LOCKED. `process_file_drop` receives a file_drop EVENT and reads the user-written commune file directly from disk (raw filesystem read yields literal tags). The body is in DECODED state when it reaches `route_inbound_commune_body`. NO decode pass needed in Plan 04 itself."

      Step 6 — Write the "Plan 02 handoff note" in the SUMMARY: "Option A LOCKED. Body state at LLM-stdin = DECODED. psyche.md absorption rules should reference this state literally (LLM sees literal `<live-context>` / `<project-context>` tags). The reframed entity invariant is: 'no transport-encoded envelope tags at LLM-stdin'; user-prose entities pass through."
    </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), Plan 04 handoff note (DECODED at process_file_drop), and Plan 02 handoff note (DECODED at LLM-stdin + reframed invariant).
    </done>
  </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 decoded AFTER body extraction (Option A LOCKED). |
  | 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-T5 | Tampering | User-prose entity `&amp;` is mistakenly stripped or transformed mid-traversal (cycle-3 HIGH reframe) | mitigate | Reframed invariant test Scenario B asserts user-prose `&amp;`, `&lt; 5`, `&amp;copy;` pass through byte-equal. Encode/decode symmetry guarantees this; the forbidden-substring list targets envelope tag PATTERNS only, never standalone entities. |
  | 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 per Option A LOCKED.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 4 base unit tests + 2 dual-`<br>` tests + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + B) pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - Cycle-3 HIGH reframed entity invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
  - 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 handoff: body state at `process_file_drop` = DECODED (Option A LOCKED; filesystem read).
  - Plan 02 has an unambiguous handoff: body state at LLM-stdin = DECODED (Option A LOCKED); psyche.md absorption rules reference this literally.
  - Cycle-3 HIGH reframed invariant verified by Scenario A + B end-to-end test.
  </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.
  - Locked option: A (no checkpoint in this plan).
  - 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 2 dual-`<br>` tests + Scenario A + Scenario B end-to-end).
  - Plan 02 handoff note: "Body state at LLM-stdin = DECODED. Reframed invariant: no transport-encoded envelope tags at LLM-stdin; user-prose entities pass through."
  - Plan 04 handoff note: "Body state at process_file_drop = DECODED (filesystem read). No decode pass needed in Plan 04."
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each cycle-2 HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>
  </content>
  </invoke>
  ---

  ### 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/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGHs + MEDIUMs
  #
  # Cycle-2 codex HIGHs addressed (the 3 NEW ones cycle-2 introduced):
  #
  #   HIGH 1 (false project_routed outcome): "route_inbound_commune_body returns project_routed: true
  #     whenever route_two_slice returns Ok. But route_two_slice may Ok(()) and silently skip the project
  #     slice when project name doesn't resolve. Result: logs/tests claim project persistence when none
  #     happened." → Task 2 introduces an explicit `RouteOutcome` enum returned by the inner route functions.
  #     `route_two_slice` (in src/owl/echo_commune.rs:237 — CYCLE-3 FILE PATH CORRECTION) gains a sibling
  #     `route_two_slice_outcome` that returns granular state:
  #       enum SliceWriteState { Written, SkippedNoSlice, SkippedNoProjectName, IoError(String) }
  #       struct RouteOutcome { live: SliceWriteState, project: SliceWriteState }
  #     `route_inbound_commune_body` propagates this. Logs say `project=written` ONLY when the project
  #     slice actually hit disk. Tests assert specific `SliceWriteState` variants.
  #
  #   HIGH 2 (monotonic guard underspecified): "strict-subset by nonblank lines heuristic. Rejects valid
  #     summarization/compaction (psyche.md ALREADY teaches LLM to summarize), AND misses stale rewrites
  #     that paraphrase or reorder same content. Wrong tool for the job." → REPLACED with explicit
  #     PRECEDENCE METADATA. Each `live_context.md` / `projects/<name>/<self_id>.md` write carries a marker
  #     comment line at the file head:
  #       <!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->
  #     The next write reads the existing marker:
  #       - direct write: ALWAYS proceeds + updates marker (source-of-truth).
  #       - llm write: reads existing marker. If existing `source=direct` AND existing `routed_at` is
  #         within the LAST echo_commune fire window (configurable; default = 60 seconds), REJECT the
  #         llm write + emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id}` warning.
  #         If existing `routed_at` is older than the window OR source=llm OR no marker present,
  #         proceed (legitimate summarization wins).
  #     This handles BOTH: (a) legitimate LLM summarization wins when newer than the staleness window,
  #     (b) stale post-direct-route LLM emit can't erase the direct-routed content within the protection
  #     window.
  #
  #   HIGH 3 (resolver helper placement): "Resolver helper placement/dependency is muddy: Plan 04 says
  #     route via Plan 01's resolver in echo_commune.rs, but wrapper should not depend on an owl module-
  #     private helper. Move the resolver to common/owlery.rs or common/tracked.rs." → RESOLVED by
  #     Plan 01 cycle-3 fix: `resolve_self_project_name_via_info_cwd` now lives in `src/common/owlery.rs`
  #     as `pub(crate)`. Plan 04 consumes it from there. NO cross-module private-helper coupling.
  #
  # Cycle-2 MEDIUMs addressed:
  #
  #   MEDIUM (files_modified omits echo_commune.rs): "Plan 04's files_modified omits src/owl/echo_commune.rs,
  #     but Task 2 wires outbound echo_commune to route_two_slice_monotonic there." → CORRECTED:
  #     files_modified now lists `src/owl/echo_commune.rs`. CYCLE-3 ADDITIONAL CORRECTION: `route_two_slice`
  #     itself ALREADY lives in `src/owl/echo_commune.rs:237` (NOT in `src/common/tracked.rs` as cycle-2
  #     prose stated). All references corrected.
  #
  #   MEDIUM (route failures need clear surface): preserved from cycle 2 — structured WARN logs with
  #     `[FILE-DROP]` and new `[ROUTE-GUARD]` prefixes; operator-visible via Select-String.
  #
  #   MEDIUM (testable seam): preserved from cycle 2 — `route_inbound_commune_body` seam is unit-testable
  #     without mocking resume_session_with_exit.
  #
  #   LOW (PowerShell only): preserved from cycle 2 — all Bash heredocs eliminated.
  #
  # Cycle-2 HIGHs/decisions preserved:
  #   - Option 1 LOCKED (wrapper direct-route before LLM handoff).
  #   - Receiver-side resolver via Plan 01's shared helper.
  #   - Phase-level invariant test (Task 5).
  #   - Final consolidated deploy.

  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 BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` (info.json `cwd` → `project_name_from_cwd_path`) — SAME shared helper as build_current_context_blocks + resolve_self_project_stamp. No third resolver site (cycle-3 HIGH 3 fix)."
      - "`route_inbound_commune_body` returns an EXPLICIT `RouteOutcome` enum reflecting whether each slice ACTUALLY hit disk — `SliceWriteState::Written`, `SkippedNoSlice`, `SkippedNoProjectName`, `IoError(String)`. Logs say `project=written` ONLY when the project slice actually persisted (cycle-3 HIGH 1 fix). No false-positive `project_routed: true`."
      - "STALE-OVERWRITE MITIGATION via PRECEDENCE METADATA (cycle-3 HIGH 2 fix): every write carries a `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker at the file head. Direct writes always proceed + update marker. LLM writes within the protection window (default 60s) after a direct write are suppressed with a `[ROUTE-GUARD]` warning. LLM writes outside the window proceed (legitimate summarization wins). NOT a strict-subset heuristic — the old heuristic over-rejected summarization and under-rejected paraphrase."
      - "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 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). Subsequent stale LLM emit within the protection window does NOT erase the marker."
      - "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`. Tests assert on specific `SliceWriteState` variants, not on boolean conflations."
      - "files_modified manifest correctly lists `src/owl/echo_commune.rs` (where `route_two_slice` actually lives — cycle-3 file-path correction)."
    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). RouteOutcome propagation. Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/owl/echo_commune.rs"
        provides: "New `route_two_slice_outcome` (returns granular RouteOutcome enum, cycle-3 HIGH 1) + new `route_two_slice_with_precedence` (precedence-metadata stale-overwrite mitigation, cycle-3 HIGH 2). Outbound `run_echo_commune` rewired to use the precedence-metadata variant."
        contains: "RouteOutcome|route_two_slice_with_precedence|SliceWriteState"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + precedence-metadata stale-overwrite mitigation tests + phase-level invariant test."
        contains: "route_inbound_commune_body|precedence"
    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/common/owlery.rs::resolve_self_project_name_via_info_cwd (Plan 01 shared helper)"
        via: "receiver-side project resolver — same source as build_current_context_blocks + resolve_self_project_stamp"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::route_two_slice_outcome (NEW)"
        to: "Granular SliceWriteState propagation (cycle-3 HIGH 1)"
        via: "explicit per-slice write state, no false project_routed=true"
        pattern: "SliceWriteState"
      - from: "src/owl/echo_commune.rs::route_two_slice_with_precedence (NEW)"
        to: "precedence metadata marker at file head"
        via: "spt:source=direct|llm spt:routed_at=ISO timestamp comparison"
        pattern: "spt:source|spt:routed_at"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with all cycle-2 + cycle-3 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 + cycle-3 fixes:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies BEFORE LLM handoff. Option 2 (round-trip-via-LLM) is DISFAVORED. No checkpoint:decision — choice is settled. (Task 1 is removed; tasks renumbered.)

  - **Receiver-side resolver via shared helper (cycle-3 HIGH 3).** Routing uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` — the SAME shared helper as `build_current_context_blocks` + `resolve_self_project_stamp`. Single source of truth. NO duplicated resolver logic in wrapper.

  - **Explicit RouteOutcome (cycle-3 HIGH 1).** `route_inbound_commune_body` returns granular per-slice write state. NO false `project_routed: true` when the project slice silently skipped.

  - **Precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).** Replace strict-subset heuristic with `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker. Direct writes always proceed. LLM writes within the protection window after a direct write are suppressed.

  - **Testable seam (codex MEDIUM).** `route_inbound_commune_body(self_id, body, source)` is unit-testable without mocking `resume_session_with_exit`.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  - **File-path correction (cycle-3).** `route_two_slice` lives in `src/owl/echo_commune.rs:237`, NOT `src/common/tracked.rs`. files_modified updated.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in DECODED form (Plan 03 LOCKED Option A — filesystem read of user-written commune file yields literal tags). NO decode pass needed in Plan 04.

  Output:
  - `route_inbound_commune_body` helper extracted as testable seam in `src/live/wrapper/mod.rs`.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs`.
  - `route_two_slice_outcome` sibling that returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` sibling that applies precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).
  - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence`.
  - End-to-end test demonstrating synchronous inbound persistence + stale-overwrite mitigation.
  - 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/owl/echo_commune.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/owl/echo_commune.rs:237 (CYCLE-3 FILE-PATH CORRECTION — `route_two_slice` lives HERE, not in `src/common/tracked.rs`):
  ```rust
  pub(crate) fn route_two_slice(
      self_id: &str,
      raw: &str,
      kind: &str,
      short_for_subject: &str,
  ) -> Result<(), String>;
  ```
  - Parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).
  - Returns `Ok(())` whether or not the project slice actually wrote (silently skips when no project resolves — this is the cycle-3 HIGH 1 false-positive surface).
  - Internally calls `route_project_slot` (private) at line 305 for the project half.

  From src/common/owlery.rs (Plan 01 outcome — cycle-3 HIGH 3 fix):
  - `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01 (cycle-3 placement: in owlery.rs, NOT echo_commune.rs). 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. Single shared helper across echo_commune + wrapper + outbound stamp.

  From Plan 03 outcome (cycle-3 LOCKED Option A):
  - Body state at wrapper file_drop boundary = DECODED. Filesystem read of user-written commune file yields literal `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`. NO decode pass needed.

  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 1: 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 = DECODED (Plan 03 LOCKED Option A)."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      Per Plan 03 LOCKED Option A, the body at process_file_drop is DECODED (filesystem read yields literal tags). NO decode pass needed in Task 2.

      Also confirm `crate::common::owlery::resolve_self_project_name_via_info_cwd` exists as `pub(crate)` (Plan 01 cycle-3 placement gate). Grep:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch
      ```
      Must return ≥1 match. If 0, Plan 01 did not land the helper in the right module — block here and fix Plan 01 before proceeding.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md; (Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read; body state at process_file_drop confirmed DECODED.
      - Shared helper `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 01 cycle-3 gate).
      - Task 2 has both decode-or-not answer (NO decode) and resolver-import path.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Add RouteOutcome enum + route_two_slice_outcome + route_two_slice_with_precedence + extract route_inbound_commune_body seam + wire process_file_drop</name>
    <files>src/owl/echo_commune.rs, src/live/wrapper/mod.rs</files>
    <behavior>
      - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs` (pub(crate) so wrapper can pattern-match).
      - `SliceWriteState` variants: `Written`, `SkippedNoSlice` (parse_two_slice produced None for this slice), `SkippedNoProjectName` (project resolver returned None), `IoError(String)`.
      - `RouteOutcome` struct: `live: SliceWriteState`, `project: SliceWriteState`.
      - New `route_two_slice_outcome(self_id, raw, kind, short_for_subject) -> RouteOutcome` — granular sibling of existing `route_two_slice`. The existing `route_two_slice` keeps its `Result<(), String>` signature for backward compat with current callers; the new `_outcome` variant is what `route_inbound_commune_body` consumes.
      - `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` for project resolution (cycle-3 HIGH 3) — NOT the old `derive_current_repo_names().first()` path at line 317. Falls back to existing resolution only when the new resolver returns None (for callers that lack a perch info.json).
      - `route_two_slice_outcome` calls return:
        - `project: SliceWriteState::SkippedNoSlice` when `parse_two_slice` finds no `<project-context>` tag.
        - `project: SliceWriteState::SkippedNoProjectName` when the resolver returns None.
        - `project: SliceWriteState::IoError(msg)` on filesystem write failure.
        - `project: SliceWriteState::Written` ONLY when the project file actually persisted (cycle-3 HIGH 1).
      - New `route_two_slice_with_precedence(self_id, raw, kind, short_for_subject, source) -> RouteOutcome` — sibling that ALSO applies precedence-metadata stale-overwrite mitigation. `source` is `"direct"` (inbound wrapper route) or `"llm"` (outbound echo_commune emit).
      - Precedence marker format (cycle-3 HIGH 2): `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n` as the FIRST line of every routed file (`live_context.md` and `projects/<name>/<self_id>.md`).
      - Marker rules:
        - On every write, prepend the marker line, then the slice body.
        - Before writing, READ the existing file. Parse any existing marker.
        - If new write source is `"direct"`: ALWAYS proceed (direct is source-of-truth). Update marker.
        - If new write source is `"llm"`: parse existing marker. If existing `source=direct` AND existing `routed_at` is within the protection window (default `PRECEDENCE_PROTECTION_SECS = 60`), REJECT the write and emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id} (existing source=direct routed_at={t}; protection window {N}s)`. Otherwise proceed.
        - If no marker present (file pre-dates the feature): treat as `source=llm routed_at=epoch`; new write proceeds.
      - `route_inbound_commune_body(self_id, body, source) -> RouteOutcome` extracted seam in `src/live/wrapper/mod.rs`. Calls `route_two_slice_with_precedence(self_id, body, "commune-inbound", &short, "direct")`. Unit-testable WITHOUT mocking `resume_session_with_exit`.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Outbound `run_echo_commune` (at echo_commune.rs:854 — verify line via grep) rewired to call `route_two_slice_with_precedence(..., "llm")` instead of `route_two_slice`.
      - Route failures from `route_inbound_commune_body` log a structured `[FILE-DROP]` WARN — non-fatal, LLM handoff continues.
      - "signoff" arm unchanged.
    </behavior>
    <action>
      Step 1 — Add the enums to `src/owl/echo_commune.rs` (near the existing `route_two_slice` definition at line 237):

      ```rust
      /// 25.3-04 Defect A (cycle-3 HIGH 1 fix): granular per-slice write outcome.
      /// Replaces the boolean conflation of `route_two_slice -> Result<(), String>`
      /// that caused logs/tests to report `project_routed: true` even when the
      /// project slice silently skipped (no project name resolved).
      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) enum SliceWriteState {
          /// Slice persisted to disk successfully.
          Written,
          /// `parse_two_slice` produced None for this slice (envelope absent).
          SkippedNoSlice,
          /// Project resolver returned None (no project for this self_id).
          /// Only meaningful for the `project` field; live slice never produces this.
          SkippedNoProjectName,
          /// IO error during write — carries the error message for logs.
          IoError(String),
      }

      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) struct RouteOutcome {
          pub(crate) live: SliceWriteState,
          pub(crate) project: SliceWriteState,
      }
      ```

      Step 2 — Add `route_two_slice_outcome` in `src/owl/echo_commune.rs` (cycle-3 HIGH 1 fix). Internally mirrors `route_two_slice` + `route_project_slot` but returns the granular outcome AND uses the Plan 01 shared resolver for project resolution:

      ```rust
      /// 25.3-04 cycle-3 HIGH 1 fix: granular-outcome variant of `route_two_slice`.
      /// Used by wrapper-inbound route + outbound run_echo_commune emit (via the
      /// precedence-wrapped sibling). Project resolution uses Plan 01's shared
      /// helper `crate::common::owlery::resolve_self_project_name_via_info_cwd`
      /// (cycle-3 HIGH 3 fix: single source of truth for project resolution).
      pub(crate) fn route_two_slice_outcome(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
      ) -> RouteOutcome {
          // ... implementation: replicate route_two_slice's live-slot logic, but
          // (a) return SliceWriteState::Written / SkippedNoSlice / IoError per slice,
          // (b) use crate::common::owlery::resolve_self_project_name_via_info_cwd
          //     for the project slice resolver (NOT derive_current_repo_names),
          // (c) return SliceWriteState::SkippedNoProjectName when resolver returns None.
      }
      ```

      Step 3 — Add `route_two_slice_with_precedence` in `src/owl/echo_commune.rs` (cycle-3 HIGH 2 fix). Wraps `route_two_slice_outcome` with precedence-metadata mitigation:

      ```rust
      const PRECEDENCE_PROTECTION_SECS: i64 = 60;
      const PRECEDENCE_MARKER_PREFIX: &str = "<!-- spt:source=";

      /// 25.3-04 cycle-3 HIGH 2 fix: precedence-metadata stale-overwrite mitigation.
      /// Replaces the cycle-2 strict-subset heuristic (which over-rejected legitimate
      /// summarization and under-rejected paraphrased rewrites).
      ///
      /// Marker format: `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n`
      /// prepended as the FIRST line of every routed file.
      ///
      /// Rules:
      ///   - source="direct" (inbound wrapper route): ALWAYS proceeds; marker updated.
      ///   - source="llm" (outbound echo_commune emit): if existing marker has
      ///     source=direct AND routed_at is within PRECEDENCE_PROTECTION_SECS,
      ///     REJECT + log `[ROUTE-GUARD] stale llm overwrite suppressed ...`.
      ///     Otherwise proceed (legitimate summarization wins).
      pub(crate) fn route_two_slice_with_precedence(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
          source: &str,  // "direct" or "llm"
      ) -> RouteOutcome { /* impl */ }

      /// Helper: parse precedence marker from existing file content.
      /// Returns (source, routed_at_iso) or None if no marker.
      fn parse_precedence_marker(content: &str) -> Option<(String, String)> { /* impl */ }

      /// Helper: prepend marker to body before write.
      fn prepend_precedence_marker(body: &str, source: &str) -> String {
          let now = crate::common::time::now_iso_utc();
          format!("<!-- spt:source={} spt:routed_at={} -->\n{}", source, now, body)
      }

      /// Helper: should `source="llm"` write be suppressed by existing direct marker?
      fn should_suppress_llm_write(existing_marker: Option<(String, String)>) -> bool {
          let (src, routed_at) = match existing_marker { Some(m) => m, None => return false };
          if src != "direct" { return false; }
          // Parse ISO8601 timestamp; compute delta to now.
          let routed_dt = match chrono::DateTime::parse_from_rfc3339(&routed_at) { Ok(d) => d, Err(_) => return false };
          let delta = chrono::Utc::now().signed_duration_since(routed_dt.with_timezone(&chrono::Utc));
          delta.num_seconds() < PRECEDENCE_PROTECTION_SECS
      }
      ```

      The implementation reads the existing file (BOTH live_context.md and project file as applicable), parses any marker line at the head, decides per-slice whether to suppress based on `source` + window, and dispatches to a marker-stripping variant of route_two_slice_outcome (or strips the marker before parsing slices and adds it back on write).

      Step 4 — Add the testable seam in `src/live/wrapper/mod.rs`:

      ```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 shared resolver from Plan 01 (`crate::common::owlery::
      /// resolve_self_project_name_via_info_cwd`), and routes each slice to disk
      /// via `route_two_slice_with_precedence` with `source="direct"` (precedence-
      /// metadata mitigation, cycle-3 HIGH 2). NO LLM handoff — caller responsibility.
      ///
      /// Returns granular `RouteOutcome` (cycle-3 HIGH 1) — never a boolean
      /// `project_routed` that lies when the project slice silently skipped.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,  // structured log tag, e.g. "commune-inbound"
      ) -> crate::owl::echo_commune::RouteOutcome {
          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: body is in DECODED form at this site (filesystem
          // read yields literal tags). NO decode pass needed.

          // Precedence-metadata source="direct": this is the wrapper-inbound route
          // and IS the source of truth at this moment. Always proceeds; updates marker.
          crate::owl::echo_commune::route_two_slice_with_precedence(
              self_id, body, source, &short, "direct",
          )
      }
      ```

      Step 5 — 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 precedence metadata on the OUTBOUND
      // echo_commune emit (route_two_slice_with_precedence with source="llm").
      if kind == "commune" {
          use crate::owl::echo_commune::SliceWriteState;
          let outcome = route_inbound_commune_body(&self.self_id, &body, "commune-inbound");
          // Cycle-3 HIGH 1 fix: log granular state, not boolean conflation.
          let live_str = match &outcome.live {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project)",  // unreachable for live; defensive
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          let project_str = match &outcome.project {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project-name)",
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          self.log(&format!(
              "[FILE-DROP] route_inbound_commune_body for {} (commune-inbound; live={}, project={})",
              self.self_id, live_str, project_str
          ));
          // Surface IO errors as WARN
          if matches!(outcome.live, SliceWriteState::IoError(_))
              || matches!(outcome.project, SliceWriteState::IoError(_))
          {
              self.log(&format!(
                  "[FILE-DROP] route failed for {} (LLM handoff continues): live={:?} project={:?}",
                  self.self_id, outcome.live, outcome.project
              ));
          }
      }
      ```

      Step 6 — Rewire outbound `run_echo_commune` in `src/owl/echo_commune.rs`. Find the existing `route_two_slice(...)` call at ~line 854 via grep:
      ```powershell
      Select-String -Path src/owl/echo_commune.rs -Pattern 'route_two_slice\(' -SimpleMatch
      ```
      Replace it with `route_two_slice_with_precedence(self_id, raw, kind, short, "llm")`. This applies the precedence-metadata mitigation to LLM-emit writes.

      Step 7 — Unit tests in `tests/native_owl.rs` targeting the seam (NO wrapper mocking — codex MEDIUM):

      Granular RouteOutcome tests (cycle-3 HIGH 1):
      - `route_inbound_commune_body_returns_written_for_both_slices_when_resolver_succeeds` — fake info.json + temp .git repo; body has both envelopes; assert `outcome.live == Written && outcome.project == Written`. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_project_name_when_resolver_returns_none` — info.json missing/cwd=psyche_dir → resolver None; body has `<project-context>`; assert `outcome.project == SkippedNoProjectName` AND no `projects/` write occurred. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_slice_when_envelope_absent` — body has only `<live-context>`; assert `outcome.project == SkippedNoSlice`. `#[serial]`.
      - `route_inbound_commune_body_returns_io_error_on_write_failure` — induce write failure (e.g. read-only dir or unwritable path); assert `outcome.project == IoError(_)`. `#[serial]`. (Skip on Windows if read-only-dir simulation is awkward; use a permission-injection helper if test scaffolds exist.)

      Precedence-metadata mitigation tests (cycle-3 HIGH 2):
      - `route_two_slice_with_precedence_direct_always_writes` — pre-populate project file with `<!-- spt:source=llm spt:routed_at=<old> -->\nllm-content`; call with `source="direct"`, new body `<project-context>direct-content</project-context>`; assert file now has `source=direct` marker + `direct-content`. `#[serial]`.
      - `route_two_slice_with_precedence_llm_suppressed_when_recent_direct_exists` — pre-populate with `source=direct` marker dated 10s ago; call with `source="llm"`; assert file UNCHANGED AND `[ROUTE-GUARD] stale llm overwrite suppressed` log emitted. `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_direct_marker_outside_window` — pre-populate with `source=direct` marker dated 120s ago (outside 60s default window); call with `source="llm"` + new body; assert file UPDATED with `source=llm` marker (legitimate summarization wins). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_existing_marker_is_llm` — pre-populate with `source=llm`; call with `source="llm"`; assert proceeds (LLM-over-LLM is normal cadence). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_no_existing_marker` — pre-populate file without marker (legacy file); call with `source="llm"`; assert proceeds with new marker prepended. `#[serial]`.
      - `route_two_slice_with_precedence_live_slice_unconditional` — verify the precedence guard applies to BOTH live + project slices uniformly (or document and test the per-slice policy explicitly). `#[serial]`.
      - `parse_precedence_marker_handles_malformed_marker_gracefully` — unit test of the parser helper.

      Helper test for the marker round-trip:
      - `precedence_marker_round_trip_preserves_body_bytes` — `prepend_precedence_marker` + strip → original body byte-equal.

      Symmetry test:
      - `route_inbound_commune_body_uses_shared_resolver` — assert (via behavior, e.g. setting up info.json that the OUTBOUND build_current_context_blocks resolves the same way) that wrapper + outbound + echo_commune all produce the same resolved project name for the same self_id. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_with_precedence 2>&1 | Select-Object -Last 30; cargo test --release precedence_marker 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`, `pub(crate)`.
      - `route_two_slice_outcome` returns granular per-slice state (cycle-3 HIGH 1).
      - `route_two_slice_with_precedence` applies precedence-metadata mitigation (cycle-3 HIGH 2).
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, returns `RouteOutcome`, unit-testable without LLM mocking.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff. Logs granular state (NOT boolean conflation).
      - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence(..., "llm")`.
      - Inbound wrapper-direct uses `route_two_slice_with_precedence(..., "direct")` (unconditional, source of truth).
      - Project resolution in `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` (cycle-3 HIGH 3 shared helper).
      - Route IO errors are structured WARN logs (operator-visible).
      - 4 granular-outcome tests + 6 precedence-mitigation tests + 1 marker round-trip + 1 symmetry test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: 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:

      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"
      ```
      Expected: first line is `<!-- spt:source=direct spt:routed_at=<ISO> -->`; subsequent lines contain `marker-25.3-04-Z`.

      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
      ```

      Step 6 — Precedence-metadata mitigation smoke (cycle-3 HIGH 2): wait for the next echo_commune fire (within 60s of the direct route) and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that would have erased the marker, the file contents are UNCHANGED and the `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line appears.
      - Wait further (>60s after the direct route) and trigger another echo_commune fire; this time the LLM emit proceeds (legitimate summarization wins after the protection window).

      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.
      - Precedence-metadata marker present at file head (`<!-- spt:source=direct ... -->`).
      - `[FILE-DROP] route_inbound_commune_body for todlando ...project=written` log line confirmed for the inbound.
      - Precedence-metadata mitigation verified: within protection window, marker preserved + `[ROUTE-GUARD] stale llm overwrite suppressed` log line appears. After window, LLM emit proceeds.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: 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 `outcome.live == SliceWriteState::Written` AND `outcome.project == SliceWriteState::Written` (cycle-3 HIGH 1 — no false-positive boolean).
      - Assert ALL FOUR invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO TRANSPORT-ENCODED ENVELOPE TAGS in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;EVENT`, `&lt;project-context`, `&lt;br&gt;`). (Defect C cycle-3 reframed 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.)
        (d) Precedence-metadata marker at file head — both files have first line matching `<!-- spt:source=direct spt:routed_at=.+ -->`. (Cycle-3 HIGH 2 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 with all cycle-3 mitigations active.
    </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 transport-encoded envelope tags, correct marker placement, AND precedence-metadata marker present.
      - `outcome.live == Written` AND `outcome.project == Written` (granular, cycle-3 HIGH 1).
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution + all cycle-3 mitigations:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers including context-sensitized legacy-perch (cycle-3 MEDIUM); shared resolver in `src/common/owlery.rs` (cycle-3 HIGH 3).
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 HIGH reframed entity invariant; dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing); static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; granular `RouteOutcome` enum (cycle-3 HIGH 1 — no false `project_routed`); precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2 — replaces strict-subset); shared resolver consumed from `src/common/owlery.rs` (cycle-3 HIGH 3); structured failure logs.
      - Cross-plan: phase-level invariant test (Task 4) jointly validates all four defects + cycle-3 mitigations.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk (with precedence marker at file head), with stale-overwrite protection against subsequent LLM drops within the 60s protection window.
    </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 3 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
         Expected: first line is `<!-- spt:source=direct spt:routed_at=... -->`; body contains `final-verify-marker-25.3`.
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
         ```
      4. Wait for next echo_commune fire within 60s; confirm marker preserved AND (if LLM emit drops the marker) `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line.
      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_with_precedence writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md` with precedence marker at file head. |
  | Precedence-metadata marker comparison | Reads existing on-disk marker; parses source + routed_at; compares against protection window. |

  ## 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 (shared helper from owlery.rs), never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice_outcome` 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 | PRECEDENCE-METADATA MITIGATION (cycle-3 HIGH 2): `route_two_slice_with_precedence` applies the marker-comparison guard on the OUTBOUND echo_commune emit path (`source="llm"`). If existing marker is `source=direct` within 60s, LLM write is SUPPRESSED + `[ROUTE-GUARD]` log surfaces. Inbound direct-route (`source="direct"`) remains unconditional (it IS the new source of truth). Replaces cycle-2 strict-subset heuristic (which over-rejected summarization). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to forge a `source=direct` marker | mitigate | The marker is APPLIED by SPT at write time — not read from envelope body. Adversarial body content containing `<!-- spt:source=direct ... -->` as user text is NOT trusted: SPT strips/replaces the head-marker on every write. If a body's first line LOOKS like a marker, it is treated as ordinary body content + a fresh SPT marker is prepended above it. Test: `precedence_marker_handles_user_body_with_fake_marker_at_head_gracefully`. |
  | T-25.3.04-T4 | Tampering | Marker timestamp manipulation (system clock skew or adversarial timestamp injection) | accept | Clock skew is a known systemic risk; the 60s window is generous. Adversarial timestamp injection is bounded by T-25.3.04-T3 (user body cannot supply markers). |
  | 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. Precedence-metadata 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 prefixes `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Granular `RouteOutcome` propagation (cycle-3 HIGH 1) ensures logs reflect actual per-slice state, not boolean conflations. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. (chrono is already in Cargo.toml for timestamp helpers.) |
  </threat_model>

  <verification>
  - Task 1: Plan 03 body-state-at-process_file_drop confirmed DECODED; shared helper presence in owlery.rs confirmed.
  - Task 2 implements Option 1 with granular RouteOutcome + precedence-metadata mitigation. Shared resolver consumed from owlery.rs.
  - Task 3 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 4 phase-level invariant test asserts joint resolution of all four defects + cycle-3 invariants (precedence marker present, granular outcomes).
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite) or existing route_two_slice tests.
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`; `route_inbound_commune_body` returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` exists with marker-comparison guard (cycle-3 HIGH 2).
  - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`; unit-testable without LLM mocking.
  - `process_file_drop` "commune" arm routes BEFORE LLM handoff; logs granular state.
  - Shared resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` consumed throughout (cycle-3 HIGH 3).
  - Outbound `run_echo_commune` uses `route_two_slice_with_precedence(..., "llm")`.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS with precedence-metadata marker at file head.
  - Stale-overwrite mitigation verified: LLM emit within 60s of direct route is SUPPRESSED; LLM emit after 60s proceeds.
  - Phase-level invariant test passes (joint resolution of all 4 defects + cycle-3 invariants).
  - All PowerShell — no Bash heredocs.
  - 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:
  - Plan 03 body-state-at-process_file_drop confirmation (DECODED per Option A LOCKED).
  - Shared-helper presence confirmation (`pub(crate) fn resolve_self_project_name_via_info_cwd` in owlery.rs).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call with granular-state log).
  - Diff summary for `src/owl/echo_commune.rs` (RouteOutcome + SliceWriteState enums + route_two_slice_outcome + route_two_slice_with_precedence; outbound rewire).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content showing precedence marker at head + `marker-25.3-04-Z`).
  - Precedence-metadata mitigation smoke verification (within-window suppression + outside-window proceed).
  - Phase-level invariant test pass evidence.
  - Codex cycle-2 HIGH/MEDIUM/LOW resolution table: each → task → verification.
    - HIGH 1 (false project_routed) → Task 2 enums + outcome propagation → granular-outcome tests.
    - HIGH 2 (monotonic guard underspecified) → Task 2 precedence-metadata variant → precedence-mitigation tests.
    - HIGH 3 (resolver helper placement) → resolved by Plan 01 cycle-3 placement → Task 1 grep gate.
    - MEDIUM (files_modified omits echo_commune.rs) → CORRECTED in manifest.
    - MEDIUM (file-path correction route_two_slice in echo_commune.rs) → CORRECTED throughout.
  - 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 persist synchronously with precedence-metadata stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>
  </content>
  </invoke>
  ---


  ## Review Instructions

  **Goal: convergence check.** For each cycle-2 HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then identify any NEW HIGH issues introduced by the cycle-3 replan.
  Use severity markers: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **CONVERGED** (no remaining HIGHs) OR **NOT CONVERGED** + list of blocking HIGHs.

preview:
  # Cross-AI Plan Review Request (Cycle 3 — Final convergence check)

  These plans went through TWO prior cycles. Cycle 1 had 9 HIGHs (all resolved). Cycle 2 introduced 3 new HIGHs (claimed resolved in cycle 3). Verify convergence.

  ## Phase 25.3 — Roadmap
  ### 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 Reviews (Cycles 1 + 2)

  ---
  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**.

  ---

  # Cycle 2 Re-Review (post-replan)

  **Reviewed at**: 2026-05-22T18:10:00-07:00

  ## Prior HIGH Resolution

  | Prior HIGH | Status | Evidence |
  |---|---|---|
  | Plan 01: `resolve_self_project_stamp` could still fall back to cwd and emit `project="tracked"` | **RESOLVED** | Plan 01 now routes both stamp and context-block resolution through `info.json.cwd` plus a final cwd fallback guard. |
  | Plan 02: Option B exceeded file scope | **RESOLVED** | Plan 02 locks Option A no-name `<project-context>` and removes parser/writer changes from scope. |
  | Plan 02: “Psyche writes file” vs “Rust routes envelope” ambiguity | **RESOLVED** | Plan 02 explicitly standardizes on “SPT routes; you emit.” |
  | Plan 02: `<project-context name="...">` vs no-name inconsistency | **RESOLVED** | Option A no-name envelope is locked throughout. |
  | Plan 03: body decoding before EVENT framing parse would be unsafe | **RESOLVED** | Plan 03 now requires body decode only after framing/body extraction. |
  | Plan 03: missing dependency on envelope-shape decision | **RESOLVED** | Option A is locked, and Plan 03 now runs before Plan 02 with that assumption. |
  | Plan 04: named envelope routing ambiguity | **RESOLVED** | No-name Option A removes `name=` routing entirely. |
  | Plan 04: stale LLM re-emit could overwrite direct-routed content | **PARTIAL** | Monotonic guard is added, but the proposed strict-subset heuristic may reject legitimate summarization or still miss stale rewrites. |
  | Plan 04: Option 2 relies on prompt compliance | **RESOLVED** | Option 1 direct wrapper routing is locked/default; Option 2 is explicitly disfavored. |

  ## Fresh Review

  ### 25.3-01

  - **MEDIUM:** Plan 01 says `"tracked"` rejection is path-context-sensitive at all three layers, but the legacy perch field branch rejects `p == "tracked"` globally. That contradicts the stated invariant, even if today that field is usually unset.
  - **MEDIUM:** `resolve_self_project_name_via_info_cwd` appears introduced as a private helper in `src/owl/echo_commune.rs`, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it `pub(crate)` in a shared module or avoid depending on it outside `echo_commune.rs`.
  - **LOW:** Adding `serial_test` is pragmatic, but it expands the dependency surface for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not blocking.

  ### 25.3-02

  - **MEDIUM:** The static prompt test checks for absence of `<project-context name=` only in `psyche.md`, while the plan’s invariant says no name examples anywhere in psyche plus skill files. Add the same guard over `commune.md` and `signoff/SKILL.md`.
  - **MEDIUM:** The plan still depends on a human/LLM smoke cycle to prove artifact materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking verification, not just a deployment smoke.
  - **LOW:** Task numbering skips from Task 0 to Task 2. Not functionally harmful, but easy to confuse during execution.

  ### 25.3-03

  - **HIGH:** Plan 03 says the LLM stdin should have no `&amp;` entities in the end-to-end test. That is too strong if user content legitimately contains a literal encoded string like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not “no entities anywhere.”
  - **MEDIUM:** `event_body_unescape` uses `<br>` -> newline first, which means a user’s literal `<br>` text will not round-trip through `event_body_escape` because `<br>` is escaped as `&lt;br&gt;`, not `<br>`. The test named “literal_br” needs to distinguish transport newline markers from user literal `<br>`.
  - **MEDIUM:** The plan still includes a blocking decision between Option A and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If convergence is desired, lock Option A unless there is a real reason to keep the checkpoint.

  ### 25.3-04

  - **HIGH:** `route_inbound_commune_body` claims `project_routed: true` whenever `route_two_slice` returns `Ok`, but existing semantics may silently drop the project slice when no project resolves. That makes logs and tests falsely report project persistence. The outcome must reflect actual writes.
  - **HIGH:** The monotonic guard is underspecified for real context evolution. “Strict subset by nonblank lines” can reject valid summarization/compaction and can fail to catch stale rewrites that paraphrase or reorder. For a safety guard, prefer marker/version metadata, append-only merge, or explicit preserve-on-conflict behavior with tests for expected LLM summarization.
  - **MEDIUM:** Plan 04’s files_modified omits `src/owl/echo_commune.rs`, but Task 2 wires outbound echo_commune to `route_two_slice_monotonic` there.
  - **MEDIUM:** The helper placement/resolver dependency is muddy: Plan 04 says route via Plan 01’s resolver in `echo_commune.rs`, but wrapper should not depend on an owl module-private helper. Move the resolver to `common/owlery.rs` or `common/tracked.rs`.
  - **LOW:** The structural source-order test for “routes before LLM handoff” is brittle. A small dependency-injected function boundary would give a stronger behavioral test.

  **Overall risk after revision**: **HIGH**. Most Cycle 1 contract issues are resolved, but convergence is not reached because Plan 03’s entity invariant and Plan 04’s false route outcome plus weak stale-overwrite guard introduce remaining HIGH risks.

  ## Cycle-3 Plans

  ### 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/common/owlery.rs
    - src/owl/echo_commune.rs
    - Cargo.toml

  autonomous: true
  requirements:
    - PROJ-RESOLVE-01
    - PROJ-RESOLVE-02
    - PROJ-RESOLVE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs (cycle-2 HIGHs already resolved)
  #
  # Cycle-2 codex MEDIUMs addressed in this update:
  #   MEDIUM: "Plan 01 says 'tracked' rejection is path-context-sensitive at all three layers, but the legacy
  #     perch field branch rejects p == 'tracked' globally. That contradicts the stated invariant, even if
  #     today that field is usually unset." → Task 1 Step 3: legacy-perch branch now context-sensitizes the
  #     "tracked" rejection. It only rejects p == "tracked" when the SAME info.json's `cwd` path resolves to
  #     psyche_dir (mirrors layer-1 rule). A user with a real `tracked` project + a legacy perch field
  #     literally written "tracked" keeps that value. New test
  #     `resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` guards this.
  #   MEDIUM: "resolve_self_project_name_via_info_cwd appears introduced as a private helper in
  #     src/owl/echo_commune.rs, but Plan 04 later wants wrapper/tracked code to reuse it. Either make it
  #     pub(crate) in a shared module or avoid depending on it outside echo_commune.rs." → Task 1 Step 3:
  #     `resolve_self_project_name_via_info_cwd` is now defined in `src/common/owlery.rs` as `pub(crate)`
  #     (NOT in echo_commune.rs). `resolve_self_project_stamp` and `build_current_context_blocks`
  #     consume it via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Plan 04's wrapper
  #     route uses the SAME path. Single source of truth.
  #   LOW (accepted, not addressed): "Adding serial_test is pragmatic, but it expands the dependency surface
  #     for env tests. A local mutex/test lock would avoid the new dev dependency, though this is not
  #     blocking." → Kept `serial_test` as a `[dev-dependencies]` entry. Trade-off documented: serial_test is
  #     dev-only, widely-used, and the local-mutex alternative is non-blocking. If reviewer escalates, the
  #     local Mutex<()> swap is a single-commit change.
  #
  # Cycle-2 HIGHs (already resolved in cycle 2 — do NOT regress):
  #   - info.json-cwd-first resolver chain (locked decision #2).
  #   - belt-and-braces "tracked" filter at all three resolver layers (path-context-sensitive).
  #   - `(name=<X>)` debug-visible token in CURRENT_PROJECT_CONTEXT header (smoke verification).
  #   - `find_git_root_basename` made `pub(crate)`.
  #   - tests/native_owl.rs removed from manifest; tests live in inline `#[cfg(test)] mod tests` blocks.

  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 → legacy perch `project` field → 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 — INCLUDING when a legacy perch `project` field happens to contain the literal string `tracked` for a real `tracked` repo (cycle-3 MEDIUM fix)."
      - "`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)."
      - "The new helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` so Plan 04's wrapper route + outbound EVENT stamp can consume the SAME helper — single source of truth (cycle-3 MEDIUM fix)."
    artifacts:
      - path: "src/common/owlery.rs"
        provides: "New `project_name_from_cwd_path(&Path) -> Option<String>` helper + new `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` (reads Self's perch info.json `cwd` field, passes Path to `project_name_from_cwd_path`). Moved here from echo_commune.rs per cycle-3 MEDIUM."
        contains: "project_name_from_cwd_path"
      - path: "src/owl/echo_commune.rs"
        provides: "Refactored `build_current_context_blocks` + `resolve_self_project_stamp` consuming the shared resolver via `crate::common::owlery::resolve_self_project_name_via_info_cwd`. Belt-and-braces `tracked` rejection at all three layers including context-sensitized legacy-perch branch."
        contains: "resolve_self_project_name_via_info_cwd"
      - 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::resolve_self_project_name_via_info_cwd"
        via: "shared helper reads Self's perch info.json cwd → project_name_from_cwd_path"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::resolve_self_project_stamp"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "same shared helper — symmetry guarantee with build_current_context_blocks"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/live/wrapper/mod.rs (Plan 04 consumer)"
        to: "src/common/owlery.rs::resolve_self_project_name_via_info_cwd"
        via: "Plan 04's wrapper route_inbound_commune_body consumes the SAME helper"
        pattern: "resolve_self_project_name_via_info_cwd"
      - 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 `project` 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 AT EVERY LAYER (cycle-3 MEDIUM fix extends path-context-sensitivity to the legacy-perch branch).

  3. **Cycle-3 MEDIUM (single source of truth):** The resolver helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)`, NOT in `src/owl/echo_commune.rs`. Plan 04's wrapper route_inbound_commune_body consumes the SAME helper from the SAME location. No cross-module private-helper coupling.

  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)` or `resolve_self_project_name_via_info_cwd(&str)` already exist in `src/common/owlery.rs`. From the grep audit (planner side), no such helpers exist — only `derive_current_repo_names()` (process-cwd) and the private `find_git_root_basename(&Path)`. Task 0 explicitly audits + reports findings before Task 1 creates the helpers.

  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) -> Option<String>` AND `resolve_self_project_name_via_info_cwd(&str) -> Option<String>` in `src/common/owlery.rs` (both `pub(crate)` so wrapper + echo_commune share). Refactors `resolve_self_project_stamp` to consume the new resolver. Adds belt-and-braces path-context-sensitive `"tracked"` rejection at ALL THREE layers including the legacy-perch branch.
  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` + `resolve_self_project_name_via_info_cwd` helpers (both `pub(crate)`); `find_git_root_basename` exposed as `pub(crate)` if needed.
  - `src/owl/echo_commune.rs` — refactored `resolve_self_project_stamp` + `build_current_context_blocks` consuming the new shared resolver.
  - `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|fn resolve_self_project' -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 helpers around it.
      - If nothing exists → Task 1 creates both helpers from scratch in `src/common/owlery.rs`.

      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` AND `resolve_self_project_name_via_info_cwd` per Task 1's spec.
    </action>
    <verify>
      <automated>Select-String -Path src/common/owlery.rs -Pattern 'fn .*project.*path|fn .*from_cwd|fn .*via_info_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 + resolve_self_project_name_via_info_cwd helpers in owlery.rs + 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_name_via_info_cwd("todlando")` when `info_file("todlando")` exists with `cwd = "C:/Users/decid/Documents/projects/claude_skill_owl"` returns `Some("claude_skill_owl")`.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json is missing returns `None` gracefully.
      - `resolve_self_project_name_via_info_cwd("todlando")` when info.json `cwd` field is None returns `None` (no fallback in this layer — caller decides next-layer behavior).
      - `resolve_self_project_stamp("todlando")` when `info_file("todlando")` exists with `cwd = "C:/.../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` is None → falls through to legacy perch `project` field. Path-context-sensitive `tracked` rejection at legacy-perch branch: only reject `p == "tracked"` when the SAME info.json's cwd resolves to psyche_dir. If cwd is unavailable, accept the legacy value (could be a real `tracked` repo).
      - `resolve_self_project_stamp("todlando")` legacy-perch path with `project = "tracked"` AND info.json cwd path resolves to a real `tracked/` repo → returns `project = "tracked"` (cycle-3 MEDIUM fix — non-regression).
      - `resolve_self_project_stamp("todlando")` final fallback layer: process cwd is psyche_dir → `stamp.project` cleared (NOT `tracked`).
      - Real-project-named-tracked at final fallback: process cwd is a real `tracked/` repo OUTSIDE psyche_dir → `stamp.project == "tracked"` preserved.
    </behavior>
    <action>
      Step 1 — Cargo.toml: add `serial_test = "3"` (or current pinned version) 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).

      Step 3 — Add TWO new helpers in `src/common/owlery.rs` immediately after `find_git_root_basename`. BOTH are `pub(crate)` so wrapper (Plan 04) + echo_commune share the single source of truth:

      First helper — path-based resolver:
      - Name: `project_name_from_cwd_path(path: &std::path::Path) -> Option<String>`
      - Resolution order (mirrors `derive_current_repo_names()` (a)+(c)):
        (a) `find_git_root_basename(path)` — walk parents for `.git`, return that dir's `file_name()`.
        (b) Fallback to `path.file_name()` (D-06 fallback equivalent).
        (c) Path-context-sensitive `tracked` rejection: if resolved name is `"tracked"` AND `path` (canonicalized best-effort) equals or is within `psyche_dir()`, return None. Otherwise pass the name through (real-project-named-tracked non-regression).
      - Returns None if `path` has no file_name component (filesystem root).
      - Doc-comment cites 25.3-01 Defect B2 + locked decision #2 + cycle-3 MEDIUM (single source of truth, used by Plan 04 wrapper too).

      Helper internal: `fn is_within_tracked_psyche_dir(path: &std::path::Path) -> bool` — best-effort canonicalize both `path` and `psyche_dir()`, return `path_canon == psyche_canon || path_canon.starts_with(&psyche_canon)`.

      Second helper — perch-info-driven resolver:
      - Name: `resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>`
      - Behavior: read `info_file(self_id)`, parse `InfoJson`, take `cwd` field (Option<String>), pass to `project_name_from_cwd_path`. Return None on any IO/parse/None-field failure (callers handle the layer-2 fallback).
      - Doc-comment: "Plan 04 wrapper route + Plan 01 echo_commune outbound EVENT stamp + build_current_context_blocks ALL consume this helper. Single source of truth (cycle-3 MEDIUM)."

      Step 4 — `src/owl/echo_commune.rs`: refactor `resolve_self_project_stamp` (lines 110-127). The refactor consumes the new shared helper from `crate::common::owlery::resolve_self_project_name_via_info_cwd` (NOT a private helper inside echo_commune.rs — cycle-3 MEDIUM).

      Resolver chain (top to bottom):
      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, set `s.project = name` and return.
      2. **Layer 2 (legacy perch `project` field — context-sensitized):** call `read_self_project_from_info(self_id)`. If `Some(p)` and `!p.is_empty()`:
         - If `p == "tracked"`: read the SAME perch info.json's `cwd` field (re-parse — or refactor `read_self_project_from_info` to return both fields). If the cwd path resolves via `project_name_from_cwd_path` to `None` (i.e. the cwd IS psyche_dir), reject — fall through to layer 3. Otherwise accept `p` (real `tracked` repo non-regression).
         - If `p != "tracked"`: accept unconditionally.
         - Set `s.project = p` and return.
      3. **Layer 3 (final cwd-stamp fallback):** `s = crate::common::git::stamp()`. If `s.project == "tracked"`, read process cwd via `std::env::current_dir()`. If `project_name_from_cwd_path(&cwd)` returns `None`, clear `s.project = String::new()` (the D-06 false positive). Otherwise leave `s.project` as-is (real `tracked` repo).

      Helper changes:
      - If `read_self_project_from_info` currently returns only `Option<String>`, EITHER extend it to also return cwd OR add a sibling helper `read_self_perch_info(self_id: &str) -> Option<InfoJson>` (preferred — returns the parsed struct so layer 2 can peek cwd). Document choice in SUMMARY.

      Remove the TODO comment at echo_commune.rs:108-109 (now fulfilled).

      Step 5 — Unit tests. Place them in the EXISTING `#[cfg(test)] mod tests` block(s):
      - `owlery.rs` tests live in that file's existing `mod tests`.
      - `echo_commune.rs` tests live in one of the existing blocks (line 1127, 1703, 1894, 2081 — choose the resolver-shaped block; if no fit, use line 1127).

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

      Tests (10 total):
      - `owlery::tests::project_name_from_cwd_path_resolves_via_git_ancestor` — 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).
      - `owlery::tests::resolve_self_project_name_via_info_cwd_reads_cwd_field` — write fake perch info.json with cwd → temp .git repo; assert helper returns Some(repo_basename). `#[serial]`.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_info_missing` — assert no panic, returns None.
      - `owlery::tests::resolve_self_project_name_via_info_cwd_returns_none_when_cwd_field_none` — assert no panic, returns None.
      - `echo_commune::tests::resolve_self_project_stamp_uses_info_json_cwd_when_present` — write fake perch info.json with cwd → temp .git repo; assert stamp.project equals the repo basename. `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_accepts_real_tracked` — info.json carries `project = "tracked"` AND `cwd` pointing to a real `tracked/` repo OUTSIDE psyche_dir; assert stamp.project == "tracked" (cycle-3 MEDIUM regression guard). `#[serial]`.
      - `echo_commune::tests::resolve_self_project_stamp_legacy_perch_field_rejects_tracked_when_cwd_is_psyche` — info.json carries `project = "tracked"` AND `cwd` pointing to psyche_dir; assert layer-2 rejected, falls through to layer 3. `#[serial]`.
      - `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; cargo test --release resolve_self_project 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `project_name_from_cwd_path` exists in `src/common/owlery.rs`, `pub(crate)`.
      - `resolve_self_project_name_via_info_cwd` exists in `src/common/owlery.rs`, `pub(crate)` (cycle-3 MEDIUM: single source of truth, NOT in echo_commune.rs).
      - `find_git_root_basename` is `pub(crate)`.
      - `resolve_self_project_stamp` consumes `crate::common::owlery::resolve_self_project_name_via_info_cwd` at layer 1.
      - All three resolver layers filter literal `"tracked"` only when the source path resolves to psyche_dir (belt-and-braces; layer 2 now context-sensitized per cycle-3 MEDIUM).
      - Real-project-named-tracked tests pass at BOTH layer 2 and layer 3 (regression guards).
      - `serial_test` is in Cargo.toml `[dev-dependencies]`.
      - All ~10 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 shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 a layered resolver chain (consuming the SHARED helper from `crate::common::owlery`):

      1. **Layer 1 (info.json cwd):** call `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)`. If `Some(name)`, use it.
      2. **Layer 2 (process-cwd fallback for callers like signoff prompt-build that may run from a real project cwd):** if layer 1 returns None, call `owlery::derive_current_repo_names().into_iter().next()`. If that returns `"tracked"`, verify via `project_name_from_cwd_path(&std::env::current_dir()?)` — if the helper returns None (psyche_dir false positive), drop. Otherwise accept (real `tracked` repo).
      3. **Result:** `Option<String>` resolved project name. If None, emit NO `CURRENT_PROJECT_CONTEXT` block.

      Output format when project resolves:
      `\n\nCURRENT_PROJECT_CONTEXT (name=<X>):\n<body>`
      where `<body>` is `std::fs::read_to_string(owlery::project_worktree_path(&name).join(format!("{}.md", self_id)))` or the literal `"(none — first commune in project)"` on read failure.

      The `(name=<X>)` parenthetical is the debug/test-visible token codex MEDIUM requested for smoke verification.

      Update the doc comment on `build_current_context_blocks` (lines 372-397):
      - Cite Defect B2 fix and the new resolver order (info-cwd via shared helper → 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`) AND Plan 04 wrapper route_inbound_commune_body consume the SAME shared resolver (`crate::common::owlery::resolve_self_project_name_via_info_cwd`). 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 → 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 `crate::common::owlery::resolve_self_project_name_via_info_cwd` first (shared helper, NOT a private function).
      - 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 + cycle-3 single-source-of-truth note.
    </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|resolve_self_project_name_via_info_cwd' -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.
      - Plan 04 wrapper consumer (NOT yet present — will appear in Plan 04). Confirm the helper signature is wrapper-friendly (pub(crate), no echo_commune-private dependencies).
      - 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), 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 — Confirm `resolve_self_project_name_via_info_cwd` visibility is `pub(crate)` (not `fn`-private). This is the cycle-3 MEDIUM gate for Plan 04 reuse.

      Step 5 — 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 6 — 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."

      Step 7 — Write a one-line "Plan 04 handoff note": "Plan 04's wrapper route MUST consume `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` — same shared helper as build_current_context_blocks + resolve_self_project_stamp. NO duplicated resolver logic in wrapper."
    </action>
    <verify>
      <automated>cargo build --release 2>&1 | Select-Object -Last 5; Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' | Measure-Object | Select-Object -ExpandProperty Count</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.
      - `resolve_self_project_name_via_info_cwd` visibility verified as `pub(crate)` (Plan 04 reuse gate).
      - Plan 02 handoff note + Plan 04 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 `resolve_self_project_name_via_info_cwd_returns_none_when_info_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 at ALL THREE layers (cycle-3 MEDIUM extension): filter `"tracked"` only when source path is psyche_dir. Real `tracked/` repo at any other path is accepted. Unit tests at each layer guard 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). Pin to major version (`"3"`). |
  </threat_model>

  <verification>
  After all 3 tasks complete:

  1. Code review of the resolver layering:
     - `project_name_from_cwd_path` (NEW in owlery.rs) is the path-based primary.
     - `resolve_self_project_name_via_info_cwd` (NEW in owlery.rs, `pub(crate)`) is the perch-info-driven entry point — SHARED by Plan 04.
     - `read_self_project_from_info` (legacy perch field) is the secondary fallback, now context-sensitized for `tracked`.
     - `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: `build_current_context_blocks`, `derive_current_repo_names`, `resolve_self_project_stamp`, and `resolve_self_project_name_via_info_cwd` callers documented.

  3. `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 04 reuse gate).

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

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

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

  <success_criteria>
  - `project_name_from_cwd_path(path)` AND `resolve_self_project_name_via_info_cwd(self_id)` both exist in `src/common/owlery.rs` as `pub(crate)`.
  - `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, INCLUDING context-sensitized legacy-perch branch (cycle-3 MEDIUM); real-project-named-tracked non-regression preserved at every layer.
  - `cargo test --release` passes including all 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 [context-sensitized] → cwd-stamp), each with its `tracked` filter point.
  - Confirmation that `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs` as `pub(crate)` (NOT in echo_commune.rs).
  - Caller audit table for `build_current_context_blocks`, `derive_current_repo_names`, and `resolve_self_project_name_via_info_cwd`.
  - Codex review resolution table: each cycle-2 MEDIUM addressed → task that closes it → test that verifies it.
  - Real-project-named-tracked non-regression note (at BOTH layer 2 and layer 3).
  - Plan 02 handoff note (artifact materialization still blocked on Defect D).
  - Plan 04 handoff note (wrapper consumes the shared helper from owlery.rs).
  - Commit SHAs for the resolver refactor + test additions + binary rebuild.
  </output>
  </content>
  </invoke>
  ---

  ### 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
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - PSYCHE-ABSORB-01
    - PSYCHE-ABSORB-02
    - PSYCHE-ABSORB-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex MEDIUMs
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (static prompt-contract test scope): "The static prompt test checks for absence of
  #     `<project-context name=` only in psyche.md, while the plan's invariant says no name examples anywhere
  #     in psyche plus skill files. Add the same guard over commune.md and signoff/SKILL.md." → Task 3 Step 4
  #     extends the static prompt-contract test to ALSO assert no `<project-context name=` substring exists
  #     in `plugin/spt/skills/commune/commune.md` AND `plugin/spt/skills/signoff/SKILL.md`. The test now reads
  #     three files and asserts the no-name guard against each.
  #   MEDIUM (smoke verification rigor): "The plan still depends on a human/LLM smoke cycle to prove artifact
  #     materialization. That is acceptable for prompt behavior, but it should be treated as a real blocking
  #     verification, not just a deployment smoke." → Task 5 already uses `checkpoint:human-verify gate=
  #     "blocking"` (cycle 2 was correct here). Reinforced in `<done>` block: operator-verify is a hard gate;
  #     a `Test-Path` false MUST be reported as a failure, not a soft warning.
  #   LOW (task numbering gap): "Task numbering skips from Task 0 to Task 2." → Task numbers normalized:
  #     Task 1 (Read Plan 01 + Plan 03 SUMMARYs), Task 2 (psyche.md edits), Task 3 (skill files),
  #     Task 4 (deploy + smoke), Task 5 (human-verify checkpoint). Sequential. No gaps.
  #
  # Cycle-2 HIGHs preserved (already resolved):
  #   - Option A locked (no-name `<project-context>` shape).
  #   - "SPT routes; you emit" phrasing.
  #   - Plan 03 encoding-contract reference (now unambiguously DECODED per cycle-3 Plan 03 lock).
  #   - depends_on: [25.3-01, 25.3-03].
  #
  # Plan 03 handoff update (cycle-3): Plan 03 LOCKED Option A. Body state at LLM-stdin = DECODED. psyche.md
  # absorption rule 5 (Encoding assumption) now states this literally: "At the LLM-stdin boundary, the EVENT
  # envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT
  # re-encode."

  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 DECODED form (Plan 03 LOCKED Option A) — so future editors can't accidentally re-encode."
      - "Static prompt-contract test (cycle-3 MEDIUM extension) asserts NO `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md — the no-name guard covers ALL three files."
    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"
      - path: "tests/native_owl.rs"
        provides: "Static prompt-contract test (cycle-3 extension) asserts no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md."
        contains: "psyche_md_contains_25_3_absorption_section"
    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: DECODED at LLM-stdin per Option A LOCKED)"
        via: "body-state-at-LLM-stdin = DECODED literally"
        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 03 LOCKED (cycle-3):** body state at LLM-stdin = DECODED. psyche.md absorption rule 5 states this literally. No more checkpoint-dependent placeholder.
  - **Plan reorder:** This plan runs AFTER Plan 03. Plan 03 sets the encoding contract (DECODED at LLM-stdin); this plan's absorption rules reference that state 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 (DECODED at LLM-stdin).
  - `commune.md` + `signoff/SKILL.md` — aligned with the same shape and phrasing.
  - `tests/native_owl.rs` — static prompt-contract test extended (cycle-3) to assert no `<project-context name=` substring in psyche.md AND commune.md AND signoff/SKILL.md.
  - 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 (DECODED at LLM-stdin per Option A LOCKED).

  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; cycle-3 LOCKED Option A):
  - Body state at LLM stdin = DECODED. psyche.md absorption rule 5 references this literally.

  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 1: 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 is DECODED (cycle-3 LOCKED Option A). psyche.md absorption rule 5 will state this literally: "At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags. Do NOT re-encode."

      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 (`CURRENT_PROJECT_CONTEXT (name=<X>):`).
      - Plan 03 body-state-at-LLM-stdin extracted (DECODED — locked Option A).
      - 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, tests/native_owl.rs</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 Plan 03 LOCKED Option A).** At the LLM-stdin boundary, the EVENT envelope body is in DECODED form — `<live-context>` and `<project-context>` are LITERAL tags (you see `<`, not `&lt;`). Do NOT re-encode. Do NOT decode. The state you see is the state SPT expects you to consume. User-prose entities inside body content (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched — they are character content, not transport encoding (cycle-3 reframed entity invariant).

      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 (cycle-2 MEDIUM + cycle-3 MEDIUM extension). Add a test in `tests/native_owl.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 in psyche.md");
          // Encoding assumption ref:
          assert!(content.contains("25.3-03") || content.contains("DECODED") || content.contains("encoding contract"),
                  "must reference Plan 03 contract (DECODED at LLM-stdin)");
      }

      /// Cycle-3 MEDIUM: extend the Option-A no-name guard to commune.md + signoff/SKILL.md.
      /// The codex review noted that the cycle-2 test only covered psyche.md; the plan's
      /// invariant says ALL three files. This test fills that gap.
      #[test]
      fn skill_files_have_no_project_context_name_attr() {
          let commune = std::fs::read_to_string("plugin/spt/skills/commune/commune.md")
              .expect("commune.md must exist");
          let signoff = std::fs::read_to_string("plugin/spt/skills/signoff/SKILL.md")
              .expect("signoff/SKILL.md must exist");
          assert!(!commune.contains("<project-context name="),
                  "Option A locked: no name= attr in commune.md (cycle-3 MEDIUM)");
          assert!(!signoff.contains("<project-context name="),
                  "Option A locked: no name= attr in signoff/SKILL.md (cycle-3 MEDIUM)");
      }
      ```

      These are static prompt-contract tests — not full LLM-output goldens, but they guard against accidental phrase drift in future edits across ALL three documents.
    </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; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 5</automated>
    </verify>
    <done>
      - New `<absorption>` section exists in psyche.md 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 rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
      - `<output_envelope>` line 322 wording updated (`<cwd_project>` removed).
      - `<context_save>` section aligned.
      - psyche.md line count increase ≤ 70 lines.
      - TWO static prompt-contract tests pass: one for psyche.md, one for commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
    </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.

      Self-verification: after edits, the `skill_files_have_no_project_context_name_attr` test from Task 2 MUST pass against these freshly-edited files. Run it as a self-check before considering this task done.
    </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 '<project-context name=' -SimpleMatch | Measure-Object | Select-Object -ExpandProperty Count; cargo test --release skill_files_have_no_project_context_name_attr 2>&1 | Select-Object -Last 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.
      - `skill_files_have_no_project_context_name_attr` test passes (cycle-3 MEDIUM gate).
    </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 (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 DECODED state per Plan 03 Option A LOCKED. Apply the cycle-3 reframed invariant: NO `&lt;EVENT`, `&lt;/EVENT`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;` substrings in the body. User-prose entities (if any in todlando's body) pass through.
    </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 DECODED state (no forbidden transport-encoded envelope-tag substrings).
    </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 (cycle-3 MEDIUM: layer-2 legacy-perch field context-sensitized); shared helper `resolve_self_project_name_via_info_cwd` lives in `src/common/owlery.rs`.
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 reframed entity invariant (no transport-encoded envelope tags at LLM-stdin); dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D, this plan): psyche.md absorption + emission rules (Option A no-name; 'SPT routes' phrasing; encoding-contract reference); skill-file alignment; static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension); 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 add wrapper-direct routing with explicit RouteOutcome + precedence-metadata stale-overwrite mitigation).
    </what-built>
    <how-to-verify>
      1. Confirm `%LOCALAPPDATA%\spt\psyches\tracked\projects\claude_skill_owl\todlando.md` exists (PowerShell: `Test-Path`). HARD GATE — false means failure (cycle-3 MEDIUM: this is a real blocking verification, not a soft warning).
      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;`) — cycle-3 reframed invariant verification (no transport-encoded envelope-tag substrings).
    </how-to-verify>
    <resume-signal>Type "approved" if the file exists with sensible content AND encoding is uniform (no transport-encoded envelope tags), 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 tests assert no `<project-context name=` substring exists in psyche.md AND commune.md AND signoff/SKILL.md (cycle-3 MEDIUM extension). |
  | 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. HARD GATE.
  - `live_context.md` for todlando continues to update normally.
  - TWO static prompt-contract tests pass (psyche.md + skill files — cycle-3 MEDIUM extension).
  - Encoding-contract smoke confirms DECODED state at LLM-stdin (cycle-3 reframed invariant).
  </verification>

  <success_criteria>
  - psyche.md `<absorption>` section exists with rules 1-5; Option A locked shape; "SPT routes" phrasing; rule 5 states DECODED literally + cycle-3 reframed entity invariant note.
  - 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 (HARD GATE).
  - Operator-verify checkpoint approved by user.
  - Static prompt-contract tests assert no `<project-context name=` substring in psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
  </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 + DECODED 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.
  - Static prompt-contract test pass evidence (both tests).
  - Codex review resolution table: each cycle-2 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 adds wrapper-direct routing with explicit RouteOutcome (no false project_routed) and precedence-metadata stale-overwrite mitigation."
  </output>
  </content>
  </invoke>
  ---

  ### 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: true
  requirements:
    - ENCODE-CONTRACT-01
    - ENCODE-CONTRACT-02
    - ENCODE-CONTRACT-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGH + MEDIUMs
  #
  # Cycle-2 codex HIGH addressed:
  #   HIGH (entity invariant too strong): "Plan 03 says the LLM stdin should have no `&amp;` entities in the
  #     end-to-end test. That is too strong if user content legitimately contains a literal encoded string
  #     like `&amp;` or `&lt;` as prose. The invariant should assert no transport-encoded envelope tags, not
  #     'no entities anywhere.'" → Task 3 Step 6 REFRAMED. The end-to-end invariant now asserts NO TRANSPORT-
  #     ENCODED ENVELOPE TAGS at LLM-stdin. The forbidden substring set is EXPLICIT:
  #       `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`,
  #       `&lt;project-context`, `&lt;/project-context`,
  #       `&lt;live-context`, `&lt;/live-context`,
  #       `&lt;br&gt;`
  #     User-content entities NOT forming envelope tags (e.g. user prose containing `&amp; rules!`, `&lt; 5`,
  #     or a literal HTML snippet `&amp;copy;`) MUST pass through untouched. The test fabricates both a body
  #     with envelope tags AND a body with user-prose entities; asserts forbidden tags absent AND user prose
  #     present byte-identical.
  #
  # Cycle-2 codex MEDIUMs addressed:
  #   MEDIUM (event_body_unescape `<br>` round-trip framing): "event_body_unescape uses `<br>` → newline first,
  #     which means a user's literal `<br>` text will not round-trip through event_body_escape because `<br>`
  #     is escaped as `&lt;br&gt;`, not `<br>`. The test named 'literal_br' needs to distinguish transport
  #     newline markers from user literal `<br>`." → Traced manually: the existing encode order DOES preserve
  #     round-trip. Input `"<br>"` → escape:`& < > " \n→<br>` order → `"&lt;br&gt;"` (the `<` is escaped
  #     BEFORE the `\n→<br>` step ever sees a `<br>` to insert). Decode order: `<br>→\n` FIRST sees no `<br>`
  #     in `"&lt;br&gt;"`; then `&lt;→<` → `"<br>"`. Round-trip CORRECT.
  #     Input `"\n"` → escape → `"<br>"` → decode: `<br>→\n` → `"\n"`. ALSO CORRECT.
  #     So the cycle-2 concern was about TEST CLARITY, not a real round-trip break. Task 3 Step 4 now has
  #     TWO clearly-named tests covering both cases:
  #       - `event_body_round_trip_preserves_user_literal_br_text` (input contains literal `<br>` characters
  #         as user prose)
  #       - `event_body_round_trip_decodes_transport_br_to_newline` (input contains a literal `\n` character
  #         that gets transport-encoded as `<br>` and must decode back to `\n`)
  #     The test asserts byte-equality across both round-trips. The encode/decode order is locked AS-IS
  #     (amp-first encode + amp-last decode + `<br>` first in decode + `\n→<br>` last in encode).
  #   MEDIUM (Option A/B checkpoint redundant): "The plan still includes a blocking decision between Option A
  #     and B even though the surrounding text strongly implies the LLM must see decoded literal tags. If
  #     convergence is desired, lock Option A unless there is a real reason to keep the checkpoint." → REMOVED
  #     the checkpoint:decision task. Option A (decode at wrapper LLM-stdin boundary) is LOCKED. Task 2 is
  #     gone. Plan 03 now has only Tasks 1, 3, 4. The freed slot is unused (Plan 03 stays at 3 tasks; the
  #     end-to-end invariant + chunk-boundary tests merged into Task 3 Steps 5-6 which were already there).
  #     Plan 04's body-state-at-process_file_drop is now an UNAMBIGUOUS handoff: DECODED (filesystem read of
  #     user-written commune file yields literal tags; no decode needed in Plan 04 itself).
  #
  # Cycle-2 LOW addressed:
  #   LOW: "Large contract doc in poll.rs may age poorly." → Task 3 Step 3 keeps the 80-line extraction
  #     threshold. Decision deterministic.
  #
  # Cycle-2 strengths preserved:
  #   - Decode happens AFTER EVENT framing parse (NOT global string unescape). Documented + tested.
  #   - Reframed invariant: "one body decode per transport traversal" (NOT "one site only").
  #   - EVENT-PART chunk-boundary tests (Task 3 Step 5).
  #   - PowerShell-only commands (no Bash heredocs).

  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 contains NO TRANSPORT-ENCODED ENVELOPE TAGS — the forbidden substrings are: `&lt;EVENT`, `&lt;/EVENT`, `&lt;EVENT-PART`, `&lt;/EVENT-PART`, `&lt;project-context`, `&lt;/project-context`, `&lt;live-context`, `&lt;/live-context`, `&lt;br&gt;`. User-prose entities NOT forming envelope tags (e.g. literal `&amp;`, `&lt; 5`, `&amp;copy;`) PASS THROUGH untouched."
      - "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)."
      - "`<br>` round-trip: user-prose literal `<br>` text and transport newline-as-`<br>` are BOTH handled correctly by the current encode order (`& < > \" \\n→<br>`) + decode order (`<br>→\\n`, `&lt;`, `&gt;`, `&quot;`, `&amp;`). Two distinct tests prove this (cycle-3 MEDIUM)."
      - "Option A is LOCKED: decode happens at the wrapper LLM-stdin boundary AFTER body extraction. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04)."
    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 TRANSPORT-ENCODED ENVELOPE TAGS in final body; user-prose entities pass through; 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:
  - **Option A is LOCKED (cycle-3):** decode happens at the wrapper LLM-stdin boundary AFTER body extraction. NO checkpoint:decision in this plan — the choice is settled. Plan 04's process_file_drop sees DECODED body (filesystem read of user-written commune file yields literal tags).
  - **Plan reorder:** This plan executes BEFORE Plan 02. Plan 02's psyche.md absorption rules reference the locked Option A.
  - **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.
  - **Cycle-3 HIGH (entity invariant reframe):** the end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched.
  - **Cycle-3 MEDIUM (`<br>` round-trip clarity):** TWO distinct tests cover (a) user-prose literal `<br>` text and (b) transport newline-as-`<br>` — both round-trip correctly under the existing encode order.

  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 3) Fixes at the source — single encode at producer; body decode AFTER body extraction at the wrapper LLM-stdin boundary (Option A LOCKED); remove any double-encodes. Writes the contract doc. Adds tests including the cycle-3 reframed entity invariant + the two-distinct-`<br>` tests + EVENT-PART chunk-boundary regressions.
  3. (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. With Option A locked, `process_file_drop` sees DECODED body — the wrapper's filesystem read of the user-written commune file yields literal tags directly. No decode pass needed in Plan 04 itself. Task 4 explicitly states this in the Plan 04 handoff note.
  </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:685-739. -->

  ```rust
  // src/owl/poll.rs:685 (encode order — amp first, \n→<br> LAST)
  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 (amp-last invariant).
  ```

  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. Option A LOCKED: site will live at the wrapper LLM-stdin boundary AFTER body extraction.

  Round-trip trace (cycle-3 MEDIUM `<br>` clarity):
  - Input `"\n"` → escape (amp-first, `\n→<br>` LAST) → `"<br>"` → decode (`<br>→\n` FIRST, then `&lt;` etc.) → `"\n"`. ✓
  - Input `"<br>"` (user prose) → escape: `&` `<` `>` `"` `\n` order → after `<` step: `"&lt;br&gt;"` → `\n` step: no `\n` so unchanged → `"&lt;br&gt;"` → decode: `<br>→\n` finds no `<br>` in `"&lt;br&gt;"` → `&lt;→<` → `"<br>"`. ✓
  - Both round-trips byte-equal. The existing encode/decode order is correct AS-IS.
  </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="auto" tdd="true">
    <name>Task 3: Add event_body_unescape, fix asymmetric site at wrapper LLM-stdin boundary (Option A LOCKED), write contract doc, add tests including cycle-3 reframed entity invariant + dual-`<br>` tests + 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. Option A LOCKED: body decode happens at the wrapper LLM-stdin boundary AFTER body extraction.
      - 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 wrapper LLM-stdin 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 ALL of: bodies containing nested tags, ampersand entities, literal `</EVENT>`, literal `<br>` (user prose), literal `\n` (transport newline).
      - 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).
      - Cycle-3 reframed entity invariant: end-to-end test asserts NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin (explicit forbidden substring list); user-prose entities pass through untouched (byte-equality).
      - Cycle-3 dual-`<br>` tests: BOTH `user-prose literal <br>` round-trip AND `transport \n encoded as <br>` round-trip are byte-equal.
      - 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.
      ///
      /// Round-trip cases (cycle-3 MEDIUM):
      /// - Input `"\n"` → escape → `"<br>"` → unescape → `"\n"`. ✓
      /// - Input `"<br>"` (user prose) → escape → `"&lt;br&gt;"` → unescape → `"<br>"`. ✓
      /// Both byte-equal under the current encode order (`& < > " \n→<br>` LAST).
      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 per Option A LOCKED. 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 wrapper LLM-stdin boundary (`resume_session_with_exit` call site) — 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 + Option A locked + 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 (wrapper LLM-stdin boundary AFTER body extraction; amp-last invariant; Option A LOCKED).
      - The decode order (`<br>` first, `&amp;` last).
      - State that spool / TCP / wrapper relay see: ENCODED bodies throughout (Option A).
      - Plan 04 process_file_drop body state: DECODED (filesystem read of user-written file yields literal tags; no decode pass needed in Plan 04).
      - 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`:
      - `event_body_unescape_inverts_event_body_escape_for_html_content` — body with `<`, `>`, `"`, `&`.
      - `event_body_unescape_inverts_event_body_escape_for_amp_first_content` — body starting with `&amp;` (amp-last invariant guard).
      - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags` — body containing `<live-context>...</live-context>`.
      - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body` — body containing `</EVENT>` — must NOT reframe.
      - **Cycle-3 MEDIUM dual-`<br>` tests** (TWO distinct cases):
        - `event_body_round_trip_preserves_user_literal_br_text` — input is user prose containing literal `<br>` text (e.g. `"line one<br>line two"` where `<br>` is meant as character content, NOT a newline marker); after escape→unescape, the literal `<br>` characters round-trip byte-equal.
        - `event_body_round_trip_decodes_transport_br_to_newline` — input contains a literal `\n` character; after escape, body contains `<br>` (the transport marker); after unescape, the `<br>` decodes back to `\n` byte-equal.
      - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once` — covers the asymmetric site directly.

      Step 5 — EVENT-PART chunk-boundary tests (cycle-2 MEDIUM, preserved):
      - `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`) — **CYCLE-3 HIGH REFRAMED INVARIANT**:

      Build TWO body scenarios:

      **Scenario A (envelope-tag body):** Fabricate a commune body containing literal `<live-context>marker-live-aaa</live-context>` AND literal `<project-context>marker-proj-bbb</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 NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin. The EXPLICIT FORBIDDEN SUBSTRING SET (test must check every one):
      ```rust
      let forbidden = [
          "&lt;EVENT",
          "&lt;/EVENT",
          "&lt;EVENT-PART",
          "&lt;/EVENT-PART",
          "&lt;project-context",
          "&lt;/project-context",
          "&lt;live-context",
          "&lt;/live-context",
          "&lt;br&gt;",
      ];
      for tag in forbidden {
          assert!(!final_body.contains(tag),
                  "LLM stdin must not contain transport-encoded envelope tag `{}` (cycle-3 reframed invariant)", tag);
      }
      ```

      Additionally assert that inner envelope tags ARE present as LITERAL `<` / `>`:
      ```rust
      assert!(final_body.contains("<live-context>marker-live-aaa</live-context>"));
      assert!(final_body.contains("<project-context>marker-proj-bbb</project-context>"));
      ```

      **Scenario B (user-prose entity body):** Fabricate a commune body where the user prose contains literal entity-shaped strings that are NOT envelope tags:
      - `"This rule about &amp; in URLs is documented."`
      - `"Use &lt; 5 in math notation."`
      - `"HTML entity for copyright is &amp;copy;."`

      Run through the same pipeline. Assert these byte-identical strings are PRESENT in the final body at LLM-stdin (user prose passes through untouched — cycle-3 HIGH reframe):
      ```rust
      assert!(final_body.contains("This rule about &amp; in URLs is documented."),
              "user-prose entity must pass through untouched (cycle-3 HIGH reframe)");
      assert!(final_body.contains("Use &lt; 5 in math notation."));
      assert!(final_body.contains("HTML entity for copyright is &amp;copy;."));
      ```

      Combined assertion logic: the forbidden-substring set is checked AGAINST envelope-tag patterns ONLY (the `&lt;EVENT`, `&lt;br&gt;`, etc.). User prose containing standalone `&amp;` or `&lt; 5` (with a space-separator that prevents tag formation) passes through.

      Document this distinction in the test's doc comment:
      > Cycle-3 HIGH reframe: the invariant is "no transport-encoded ENVELOPE TAGS at LLM-stdin", NOT "no entities at LLM-stdin". User prose may legitimately contain `&amp;`, `&lt;`, `&gt;`, `&quot;` as character content; those round-trip through the transport because the encode/decode pair is symmetric AND those characters never form structural envelope tokens by themselves.
    </action>
    <verify>
      <automated>cargo test --release event_body_unescape 2>&1 | Select-Object -Last 30; cargo test --release event_body_round_trip 2>&1 | Select-Object -Last 30; cargo test --release event_part_chunk_boundary 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 per Option A LOCKED.
      - 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."
      - 4 base unit tests + 2 dual-`<br>` tests (cycle-3 MEDIUM) + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + Scenario B) pass.
      - Cycle-3 HIGH reframed invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
      - 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).

      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 transport-encoded envelope tags (cycle-3 reframed invariant)
      ```
      Plan 02's Task 4 will execute the deploy + run this trace.

      Step 5 — Write the explicit "Plan 04 handoff note" in the SUMMARY: "Option A LOCKED. `process_file_drop` receives a file_drop EVENT and reads the user-written commune file directly from disk (raw filesystem read yields literal tags). The body is in DECODED state when it reaches `route_inbound_commune_body`. NO decode pass needed in Plan 04 itself."

      Step 6 — Write the "Plan 02 handoff note" in the SUMMARY: "Option A LOCKED. Body state at LLM-stdin = DECODED. psyche.md absorption rules should reference this state literally (LLM sees literal `<live-context>` / `<project-context>` tags). The reframed entity invariant is: 'no transport-encoded envelope tags at LLM-stdin'; user-prose entities pass through."
    </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), Plan 04 handoff note (DECODED at process_file_drop), and Plan 02 handoff note (DECODED at LLM-stdin + reframed invariant).
    </done>
  </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 decoded AFTER body extraction (Option A LOCKED). |
  | 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-T5 | Tampering | User-prose entity `&amp;` is mistakenly stripped or transformed mid-traversal (cycle-3 HIGH reframe) | mitigate | Reframed invariant test Scenario B asserts user-prose `&amp;`, `&lt; 5`, `&amp;copy;` pass through byte-equal. Encode/decode symmetry guarantees this; the forbidden-substring list targets envelope tag PATTERNS only, never standalone entities. |
  | 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 per Option A LOCKED.
  - Single source-of-truth contract doc block exists in one place (poll.rs or encoding.rs per threshold).
  - 4 base unit tests + 2 dual-`<br>` tests + 1 compose test + 3 chunk-boundary tests + 1 end-to-end test (Scenario A + B) pass.
  - Full `cargo test --release` suite passes.
  - Reframed invariant "one body decode per transport traversal" is documented and enforced.
  - Cycle-3 HIGH reframed entity invariant: forbidden substrings explicit; user-prose entities pass through byte-equal.
  - 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 handoff: body state at `process_file_drop` = DECODED (Option A LOCKED; filesystem read).
  - Plan 02 has an unambiguous handoff: body state at LLM-stdin = DECODED (Option A LOCKED); psyche.md absorption rules reference this literally.
  - Cycle-3 HIGH reframed invariant verified by Scenario A + B end-to-end test.
  </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.
  - Locked option: A (no checkpoint in this plan).
  - 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 2 dual-`<br>` tests + Scenario A + Scenario B end-to-end).
  - Plan 02 handoff note: "Body state at LLM-stdin = DECODED. Reframed invariant: no transport-encoded envelope tags at LLM-stdin; user-prose entities pass through."
  - Plan 04 handoff note: "Body state at process_file_drop = DECODED (filesystem read). No decode pass needed in Plan 04."
  - Post-deploy smoke command (PowerShell) for Plan 02 to execute.
  - Codex review resolution table: each cycle-2 HIGH/MEDIUM/LOW addressed → task that closes it → test/doc that verifies it.
  </output>
  </content>
  </invoke>
  ---

  ### 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/owl/echo_commune.rs
    - tests/native_owl.rs
  autonomous: false
  requirements:
    - WRAPPER-ROUTE-01
    - WRAPPER-ROUTE-02
    - WRAPPER-ROUTE-03

  # CYCLE-3 NOTE — surgical fixes to cycle-2 codex HIGHs + MEDIUMs
  #
  # Cycle-2 codex HIGHs addressed (the 3 NEW ones cycle-2 introduced):
  #
  #   HIGH 1 (false project_routed outcome): "route_inbound_commune_body returns project_routed: true
  #     whenever route_two_slice returns Ok. But route_two_slice may Ok(()) and silently skip the project
  #     slice when project name doesn't resolve. Result: logs/tests claim project persistence when none
  #     happened." → Task 2 introduces an explicit `RouteOutcome` enum returned by the inner route functions.
  #     `route_two_slice` (in src/owl/echo_commune.rs:237 — CYCLE-3 FILE PATH CORRECTION) gains a sibling
  #     `route_two_slice_outcome` that returns granular state:
  #       enum SliceWriteState { Written, SkippedNoSlice, SkippedNoProjectName, IoError(String) }
  #       struct RouteOutcome { live: SliceWriteState, project: SliceWriteState }
  #     `route_inbound_commune_body` propagates this. Logs say `project=written` ONLY when the project
  #     slice actually hit disk. Tests assert specific `SliceWriteState` variants.
  #
  #   HIGH 2 (monotonic guard underspecified): "strict-subset by nonblank lines heuristic. Rejects valid
  #     summarization/compaction (psyche.md ALREADY teaches LLM to summarize), AND misses stale rewrites
  #     that paraphrase or reorder same content. Wrong tool for the job." → REPLACED with explicit
  #     PRECEDENCE METADATA. Each `live_context.md` / `projects/<name>/<self_id>.md` write carries a marker
  #     comment line at the file head:
  #       <!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->
  #     The next write reads the existing marker:
  #       - direct write: ALWAYS proceeds + updates marker (source-of-truth).
  #       - llm write: reads existing marker. If existing `source=direct` AND existing `routed_at` is
  #         within the LAST echo_commune fire window (configurable; default = 60 seconds), REJECT the
  #         llm write + emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id}` warning.
  #         If existing `routed_at` is older than the window OR source=llm OR no marker present,
  #         proceed (legitimate summarization wins).
  #     This handles BOTH: (a) legitimate LLM summarization wins when newer than the staleness window,
  #     (b) stale post-direct-route LLM emit can't erase the direct-routed content within the protection
  #     window.
  #
  #   HIGH 3 (resolver helper placement): "Resolver helper placement/dependency is muddy: Plan 04 says
  #     route via Plan 01's resolver in echo_commune.rs, but wrapper should not depend on an owl module-
  #     private helper. Move the resolver to common/owlery.rs or common/tracked.rs." → RESOLVED by
  #     Plan 01 cycle-3 fix: `resolve_self_project_name_via_info_cwd` now lives in `src/common/owlery.rs`
  #     as `pub(crate)`. Plan 04 consumes it from there. NO cross-module private-helper coupling.
  #
  # Cycle-2 MEDIUMs addressed:
  #
  #   MEDIUM (files_modified omits echo_commune.rs): "Plan 04's files_modified omits src/owl/echo_commune.rs,
  #     but Task 2 wires outbound echo_commune to route_two_slice_monotonic there." → CORRECTED:
  #     files_modified now lists `src/owl/echo_commune.rs`. CYCLE-3 ADDITIONAL CORRECTION: `route_two_slice`
  #     itself ALREADY lives in `src/owl/echo_commune.rs:237` (NOT in `src/common/tracked.rs` as cycle-2
  #     prose stated). All references corrected.
  #
  #   MEDIUM (route failures need clear surface): preserved from cycle 2 — structured WARN logs with
  #     `[FILE-DROP]` and new `[ROUTE-GUARD]` prefixes; operator-visible via Select-String.
  #
  #   MEDIUM (testable seam): preserved from cycle 2 — `route_inbound_commune_body` seam is unit-testable
  #     without mocking resume_session_with_exit.
  #
  #   LOW (PowerShell only): preserved from cycle 2 — all Bash heredocs eliminated.
  #
  # Cycle-2 HIGHs/decisions preserved:
  #   - Option 1 LOCKED (wrapper direct-route before LLM handoff).
  #   - Receiver-side resolver via Plan 01's shared helper.
  #   - Phase-level invariant test (Task 5).
  #   - Final consolidated deploy.

  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 BEFORE LLM handoff. Option 1 is LOCKED (codex HIGH disfavored Option 2)."
      - "Routing uses Plan 01's receiver-side resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` (info.json `cwd` → `project_name_from_cwd_path`) — SAME shared helper as build_current_context_blocks + resolve_self_project_stamp. No third resolver site (cycle-3 HIGH 3 fix)."
      - "`route_inbound_commune_body` returns an EXPLICIT `RouteOutcome` enum reflecting whether each slice ACTUALLY hit disk — `SliceWriteState::Written`, `SkippedNoSlice`, `SkippedNoProjectName`, `IoError(String)`. Logs say `project=written` ONLY when the project slice actually persisted (cycle-3 HIGH 1 fix). No false-positive `project_routed: true`."
      - "STALE-OVERWRITE MITIGATION via PRECEDENCE METADATA (cycle-3 HIGH 2 fix): every write carries a `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker at the file head. Direct writes always proceed + update marker. LLM writes within the protection window (default 60s) after a direct write are suppressed with a `[ROUTE-GUARD]` warning. LLM writes outside the window proceed (legitimate summarization wins). NOT a strict-subset heuristic — the old heuristic over-rejected summarization and under-rejected paraphrase."
      - "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 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). Subsequent stale LLM emit within the protection window does NOT erase the marker."
      - "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`. Tests assert on specific `SliceWriteState` variants, not on boolean conflations."
      - "files_modified manifest correctly lists `src/owl/echo_commune.rs` (where `route_two_slice` actually lives — cycle-3 file-path correction)."
    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). RouteOutcome propagation. Structured warning logs on route failure."
        contains: "route_inbound_commune_body"
      - path: "src/owl/echo_commune.rs"
        provides: "New `route_two_slice_outcome` (returns granular RouteOutcome enum, cycle-3 HIGH 1) + new `route_two_slice_with_precedence` (precedence-metadata stale-overwrite mitigation, cycle-3 HIGH 2). Outbound `run_echo_commune` rewired to use the precedence-metadata variant."
        contains: "RouteOutcome|route_two_slice_with_precedence|SliceWriteState"
      - path: "tests/native_owl.rs"
        provides: "Unit tests targeting `route_inbound_commune_body` seam (no wrapper mocking) + end-to-end inbound persistence test + precedence-metadata stale-overwrite mitigation tests + phase-level invariant test."
        contains: "route_inbound_commune_body|precedence"
    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/common/owlery.rs::resolve_self_project_name_via_info_cwd (Plan 01 shared helper)"
        via: "receiver-side project resolver — same source as build_current_context_blocks + resolve_self_project_stamp"
        pattern: "resolve_self_project_name_via_info_cwd"
      - from: "src/owl/echo_commune.rs::route_two_slice_outcome (NEW)"
        to: "Granular SliceWriteState propagation (cycle-3 HIGH 1)"
        via: "explicit per-slice write state, no false project_routed=true"
        pattern: "SliceWriteState"
      - from: "src/owl/echo_commune.rs::route_two_slice_with_precedence (NEW)"
        to: "precedence metadata marker at file head"
        via: "spt:source=direct|llm spt:routed_at=ISO timestamp comparison"
        pattern: "spt:source|spt:routed_at"
  ---

  <objective>
  Implement Defect A from `.planning/debug/todlando-project-context-not-persisted.md` with all cycle-2 + cycle-3 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 + cycle-3 fixes:

  - **Option 1 LOCKED.** The wrapper synchronously routes inbound `<project-context>` bodies BEFORE LLM handoff. Option 2 (round-trip-via-LLM) is DISFAVORED. No checkpoint:decision — choice is settled. (Task 1 is removed; tasks renumbered.)

  - **Receiver-side resolver via shared helper (cycle-3 HIGH 3).** Routing uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` — the SAME shared helper as `build_current_context_blocks` + `resolve_self_project_stamp`. Single source of truth. NO duplicated resolver logic in wrapper.

  - **Explicit RouteOutcome (cycle-3 HIGH 1).** `route_inbound_commune_body` returns granular per-slice write state. NO false `project_routed: true` when the project slice silently skipped.

  - **Precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).** Replace strict-subset heuristic with `<!-- spt:source=direct|llm spt:routed_at=ISO -->` marker. Direct writes always proceed. LLM writes within the protection window after a direct write are suppressed.

  - **Testable seam (codex MEDIUM).** `route_inbound_commune_body(self_id, body, source)` is unit-testable without mocking `resume_session_with_exit`.

  - **Visible failures (codex MEDIUM).** Route failures emit structured WARN logs; non-fatal but operator-visible.

  - **File-path correction (cycle-3).** `route_two_slice` lives in `src/owl/echo_commune.rs:237`, NOT `src/common/tracked.rs`. files_modified updated.

  This plan DEPENDS on Plan 03's encoding contract: at the wrapper file_drop boundary, the body is in DECODED form (Plan 03 LOCKED Option A — filesystem read of user-written commune file yields literal tags). NO decode pass needed in Plan 04.

  Output:
  - `route_inbound_commune_body` helper extracted as testable seam in `src/live/wrapper/mod.rs`.
  - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff.
  - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs`.
  - `route_two_slice_outcome` sibling that returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` sibling that applies precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2).
  - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence`.
  - End-to-end test demonstrating synchronous inbound persistence + stale-overwrite mitigation.
  - 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/owl/echo_commune.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/owl/echo_commune.rs:237 (CYCLE-3 FILE-PATH CORRECTION — `route_two_slice` lives HERE, not in `src/common/tracked.rs`):
  ```rust
  pub(crate) fn route_two_slice(
      self_id: &str,
      raw: &str,
      kind: &str,
      short_for_subject: &str,
  ) -> Result<(), String>;
  ```
  - Parses literal `<live-context>` / `<project-context>` tags (no-name shape; Option A locked).
  - Returns `Ok(())` whether or not the project slice actually wrote (silently skips when no project resolves — this is the cycle-3 HIGH 1 false-positive surface).
  - Internally calls `route_project_slot` (private) at line 305 for the project half.

  From src/common/owlery.rs (Plan 01 outcome — cycle-3 HIGH 3 fix):
  - `pub(crate) fn resolve_self_project_name_via_info_cwd(self_id: &str) -> Option<String>` — NEW from Plan 01 (cycle-3 placement: in owlery.rs, NOT echo_commune.rs). 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. Single shared helper across echo_commune + wrapper + outbound stamp.

  From Plan 03 outcome (cycle-3 LOCKED Option A):
  - Body state at wrapper file_drop boundary = DECODED. Filesystem read of user-written commune file yields literal `<live-context>` / `<project-context>` tags suitable for `parse_two_slice`. NO decode pass needed.

  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 1: 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 = DECODED (Plan 03 LOCKED Option A)."

      Document the exact state in this plan's SUMMARY. Task 2 references this state.

      Per Plan 03 LOCKED Option A, the body at process_file_drop is DECODED (filesystem read yields literal tags). NO decode pass needed in Task 2.

      Also confirm `crate::common::owlery::resolve_self_project_name_via_info_cwd` exists as `pub(crate)` (Plan 01 cycle-3 placement gate). Grep:
      ```powershell
      Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch
      ```
      Must return ≥1 match. If 0, Plan 01 did not land the helper in the right module — block here and fix Plan 01 before proceeding.
    </action>
    <verify>
      <automated>Test-Path .planning/phases/25.3-project-context-envelope-persistence-encoding-defects/25.3-03-SUMMARY.md; (Select-String -Path src/common/owlery.rs -Pattern 'pub\(crate\) fn resolve_self_project_name_via_info_cwd' -SimpleMatch | Measure-Object).Count</automated>
    </verify>
    <done>
      - Plan 03 SUMMARY read; body state at process_file_drop confirmed DECODED.
      - Shared helper `resolve_self_project_name_via_info_cwd` confirmed `pub(crate)` in `src/common/owlery.rs` (Plan 01 cycle-3 gate).
      - Task 2 has both decode-or-not answer (NO decode) and resolver-import path.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Add RouteOutcome enum + route_two_slice_outcome + route_two_slice_with_precedence + extract route_inbound_commune_body seam + wire process_file_drop</name>
    <files>src/owl/echo_commune.rs, src/live/wrapper/mod.rs</files>
    <behavior>
      - `RouteOutcome` + `SliceWriteState` enums added to `src/owl/echo_commune.rs` (pub(crate) so wrapper can pattern-match).
      - `SliceWriteState` variants: `Written`, `SkippedNoSlice` (parse_two_slice produced None for this slice), `SkippedNoProjectName` (project resolver returned None), `IoError(String)`.
      - `RouteOutcome` struct: `live: SliceWriteState`, `project: SliceWriteState`.
      - New `route_two_slice_outcome(self_id, raw, kind, short_for_subject) -> RouteOutcome` — granular sibling of existing `route_two_slice`. The existing `route_two_slice` keeps its `Result<(), String>` signature for backward compat with current callers; the new `_outcome` variant is what `route_inbound_commune_body` consumes.
      - `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd(self_id)` for project resolution (cycle-3 HIGH 3) — NOT the old `derive_current_repo_names().first()` path at line 317. Falls back to existing resolution only when the new resolver returns None (for callers that lack a perch info.json).
      - `route_two_slice_outcome` calls return:
        - `project: SliceWriteState::SkippedNoSlice` when `parse_two_slice` finds no `<project-context>` tag.
        - `project: SliceWriteState::SkippedNoProjectName` when the resolver returns None.
        - `project: SliceWriteState::IoError(msg)` on filesystem write failure.
        - `project: SliceWriteState::Written` ONLY when the project file actually persisted (cycle-3 HIGH 1).
      - New `route_two_slice_with_precedence(self_id, raw, kind, short_for_subject, source) -> RouteOutcome` — sibling that ALSO applies precedence-metadata stale-overwrite mitigation. `source` is `"direct"` (inbound wrapper route) or `"llm"` (outbound echo_commune emit).
      - Precedence marker format (cycle-3 HIGH 2): `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n` as the FIRST line of every routed file (`live_context.md` and `projects/<name>/<self_id>.md`).
      - Marker rules:
        - On every write, prepend the marker line, then the slice body.
        - Before writing, READ the existing file. Parse any existing marker.
        - If new write source is `"direct"`: ALWAYS proceed (direct is source-of-truth). Update marker.
        - If new write source is `"llm"`: parse existing marker. If existing `source=direct` AND existing `routed_at` is within the protection window (default `PRECEDENCE_PROTECTION_SECS = 60`), REJECT the write and emit `[ROUTE-GUARD] stale llm overwrite suppressed for {self_id} (existing source=direct routed_at={t}; protection window {N}s)`. Otherwise proceed.
        - If no marker present (file pre-dates the feature): treat as `source=llm routed_at=epoch`; new write proceeds.
      - `route_inbound_commune_body(self_id, body, source) -> RouteOutcome` extracted seam in `src/live/wrapper/mod.rs`. Calls `route_two_slice_with_precedence(self_id, body, "commune-inbound", &short, "direct")`. Unit-testable WITHOUT mocking `resume_session_with_exit`.
      - `process_file_drop`'s "commune" arm calls `route_inbound_commune_body` BEFORE composing the LLM envelope.
      - Outbound `run_echo_commune` (at echo_commune.rs:854 — verify line via grep) rewired to call `route_two_slice_with_precedence(..., "llm")` instead of `route_two_slice`.
      - Route failures from `route_inbound_commune_body` log a structured `[FILE-DROP]` WARN — non-fatal, LLM handoff continues.
      - "signoff" arm unchanged.
    </behavior>
    <action>
      Step 1 — Add the enums to `src/owl/echo_commune.rs` (near the existing `route_two_slice` definition at line 237):

      ```rust
      /// 25.3-04 Defect A (cycle-3 HIGH 1 fix): granular per-slice write outcome.
      /// Replaces the boolean conflation of `route_two_slice -> Result<(), String>`
      /// that caused logs/tests to report `project_routed: true` even when the
      /// project slice silently skipped (no project name resolved).
      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) enum SliceWriteState {
          /// Slice persisted to disk successfully.
          Written,
          /// `parse_two_slice` produced None for this slice (envelope absent).
          SkippedNoSlice,
          /// Project resolver returned None (no project for this self_id).
          /// Only meaningful for the `project` field; live slice never produces this.
          SkippedNoProjectName,
          /// IO error during write — carries the error message for logs.
          IoError(String),
      }

      #[derive(Debug, Clone, PartialEq, Eq)]
      pub(crate) struct RouteOutcome {
          pub(crate) live: SliceWriteState,
          pub(crate) project: SliceWriteState,
      }
      ```

      Step 2 — Add `route_two_slice_outcome` in `src/owl/echo_commune.rs` (cycle-3 HIGH 1 fix). Internally mirrors `route_two_slice` + `route_project_slot` but returns the granular outcome AND uses the Plan 01 shared resolver for project resolution:

      ```rust
      /// 25.3-04 cycle-3 HIGH 1 fix: granular-outcome variant of `route_two_slice`.
      /// Used by wrapper-inbound route + outbound run_echo_commune emit (via the
      /// precedence-wrapped sibling). Project resolution uses Plan 01's shared
      /// helper `crate::common::owlery::resolve_self_project_name_via_info_cwd`
      /// (cycle-3 HIGH 3 fix: single source of truth for project resolution).
      pub(crate) fn route_two_slice_outcome(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
      ) -> RouteOutcome {
          // ... implementation: replicate route_two_slice's live-slot logic, but
          // (a) return SliceWriteState::Written / SkippedNoSlice / IoError per slice,
          // (b) use crate::common::owlery::resolve_self_project_name_via_info_cwd
          //     for the project slice resolver (NOT derive_current_repo_names),
          // (c) return SliceWriteState::SkippedNoProjectName when resolver returns None.
      }
      ```

      Step 3 — Add `route_two_slice_with_precedence` in `src/owl/echo_commune.rs` (cycle-3 HIGH 2 fix). Wraps `route_two_slice_outcome` with precedence-metadata mitigation:

      ```rust
      const PRECEDENCE_PROTECTION_SECS: i64 = 60;
      const PRECEDENCE_MARKER_PREFIX: &str = "<!-- spt:source=";

      /// 25.3-04 cycle-3 HIGH 2 fix: precedence-metadata stale-overwrite mitigation.
      /// Replaces the cycle-2 strict-subset heuristic (which over-rejected legitimate
      /// summarization and under-rejected paraphrased rewrites).
      ///
      /// Marker format: `<!-- spt:source=direct|llm spt:routed_at=<ISO8601> -->\n`
      /// prepended as the FIRST line of every routed file.
      ///
      /// Rules:
      ///   - source="direct" (inbound wrapper route): ALWAYS proceeds; marker updated.
      ///   - source="llm" (outbound echo_commune emit): if existing marker has
      ///     source=direct AND routed_at is within PRECEDENCE_PROTECTION_SECS,
      ///     REJECT + log `[ROUTE-GUARD] stale llm overwrite suppressed ...`.
      ///     Otherwise proceed (legitimate summarization wins).
      pub(crate) fn route_two_slice_with_precedence(
          self_id: &str,
          raw: &str,
          kind: &str,
          short_for_subject: &str,
          source: &str,  // "direct" or "llm"
      ) -> RouteOutcome { /* impl */ }

      /// Helper: parse precedence marker from existing file content.
      /// Returns (source, routed_at_iso) or None if no marker.
      fn parse_precedence_marker(content: &str) -> Option<(String, String)> { /* impl */ }

      /// Helper: prepend marker to body before write.
      fn prepend_precedence_marker(body: &str, source: &str) -> String {
          let now = crate::common::time::now_iso_utc();
          format!("<!-- spt:source={} spt:routed_at={} -->\n{}", source, now, body)
      }

      /// Helper: should `source="llm"` write be suppressed by existing direct marker?
      fn should_suppress_llm_write(existing_marker: Option<(String, String)>) -> bool {
          let (src, routed_at) = match existing_marker { Some(m) => m, None => return false };
          if src != "direct" { return false; }
          // Parse ISO8601 timestamp; compute delta to now.
          let routed_dt = match chrono::DateTime::parse_from_rfc3339(&routed_at) { Ok(d) => d, Err(_) => return false };
          let delta = chrono::Utc::now().signed_duration_since(routed_dt.with_timezone(&chrono::Utc));
          delta.num_seconds() < PRECEDENCE_PROTECTION_SECS
      }
      ```

      The implementation reads the existing file (BOTH live_context.md and project file as applicable), parses any marker line at the head, decides per-slice whether to suppress based on `source` + window, and dispatches to a marker-stripping variant of route_two_slice_outcome (or strips the marker before parsing slices and adds it back on write).

      Step 4 — Add the testable seam in `src/live/wrapper/mod.rs`:

      ```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 shared resolver from Plan 01 (`crate::common::owlery::
      /// resolve_self_project_name_via_info_cwd`), and routes each slice to disk
      /// via `route_two_slice_with_precedence` with `source="direct"` (precedence-
      /// metadata mitigation, cycle-3 HIGH 2). NO LLM handoff — caller responsibility.
      ///
      /// Returns granular `RouteOutcome` (cycle-3 HIGH 1) — never a boolean
      /// `project_routed` that lies when the project slice silently skipped.
      ///
      /// Unit-testable WITHOUT mocking resume_session_with_exit (codex MEDIUM).
      pub(crate) fn route_inbound_commune_body(
          self_id: &str,
          body: &str,
          source: &str,  // structured log tag, e.g. "commune-inbound"
      ) -> crate::owl::echo_commune::RouteOutcome {
          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: body is in DECODED form at this site (filesystem
          // read yields literal tags). NO decode pass needed.

          // Precedence-metadata source="direct": this is the wrapper-inbound route
          // and IS the source of truth at this moment. Always proceeds; updates marker.
          crate::owl::echo_commune::route_two_slice_with_precedence(
              self_id, body, source, &short, "direct",
          )
      }
      ```

      Step 5 — 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 precedence metadata on the OUTBOUND
      // echo_commune emit (route_two_slice_with_precedence with source="llm").
      if kind == "commune" {
          use crate::owl::echo_commune::SliceWriteState;
          let outcome = route_inbound_commune_body(&self.self_id, &body, "commune-inbound");
          // Cycle-3 HIGH 1 fix: log granular state, not boolean conflation.
          let live_str = match &outcome.live {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project)",  // unreachable for live; defensive
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          let project_str = match &outcome.project {
              SliceWriteState::Written => "written",
              SliceWriteState::SkippedNoSlice => "skipped(no-slice)",
              SliceWriteState::SkippedNoProjectName => "skipped(no-project-name)",
              SliceWriteState::IoError(e) => &format!("io-error({})", e),
          };
          self.log(&format!(
              "[FILE-DROP] route_inbound_commune_body for {} (commune-inbound; live={}, project={})",
              self.self_id, live_str, project_str
          ));
          // Surface IO errors as WARN
          if matches!(outcome.live, SliceWriteState::IoError(_))
              || matches!(outcome.project, SliceWriteState::IoError(_))
          {
              self.log(&format!(
                  "[FILE-DROP] route failed for {} (LLM handoff continues): live={:?} project={:?}",
                  self.self_id, outcome.live, outcome.project
              ));
          }
      }
      ```

      Step 6 — Rewire outbound `run_echo_commune` in `src/owl/echo_commune.rs`. Find the existing `route_two_slice(...)` call at ~line 854 via grep:
      ```powershell
      Select-String -Path src/owl/echo_commune.rs -Pattern 'route_two_slice\(' -SimpleMatch
      ```
      Replace it with `route_two_slice_with_precedence(self_id, raw, kind, short, "llm")`. This applies the precedence-metadata mitigation to LLM-emit writes.

      Step 7 — Unit tests in `tests/native_owl.rs` targeting the seam (NO wrapper mocking — codex MEDIUM):

      Granular RouteOutcome tests (cycle-3 HIGH 1):
      - `route_inbound_commune_body_returns_written_for_both_slices_when_resolver_succeeds` — fake info.json + temp .git repo; body has both envelopes; assert `outcome.live == Written && outcome.project == Written`. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_project_name_when_resolver_returns_none` — info.json missing/cwd=psyche_dir → resolver None; body has `<project-context>`; assert `outcome.project == SkippedNoProjectName` AND no `projects/` write occurred. `#[serial]`.
      - `route_inbound_commune_body_returns_skipped_no_slice_when_envelope_absent` — body has only `<live-context>`; assert `outcome.project == SkippedNoSlice`. `#[serial]`.
      - `route_inbound_commune_body_returns_io_error_on_write_failure` — induce write failure (e.g. read-only dir or unwritable path); assert `outcome.project == IoError(_)`. `#[serial]`. (Skip on Windows if read-only-dir simulation is awkward; use a permission-injection helper if test scaffolds exist.)

      Precedence-metadata mitigation tests (cycle-3 HIGH 2):
      - `route_two_slice_with_precedence_direct_always_writes` — pre-populate project file with `<!-- spt:source=llm spt:routed_at=<old> -->\nllm-content`; call with `source="direct"`, new body `<project-context>direct-content</project-context>`; assert file now has `source=direct` marker + `direct-content`. `#[serial]`.
      - `route_two_slice_with_precedence_llm_suppressed_when_recent_direct_exists` — pre-populate with `source=direct` marker dated 10s ago; call with `source="llm"`; assert file UNCHANGED AND `[ROUTE-GUARD] stale llm overwrite suppressed` log emitted. `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_direct_marker_outside_window` — pre-populate with `source=direct` marker dated 120s ago (outside 60s default window); call with `source="llm"` + new body; assert file UPDATED with `source=llm` marker (legitimate summarization wins). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_existing_marker_is_llm` — pre-populate with `source=llm`; call with `source="llm"`; assert proceeds (LLM-over-LLM is normal cadence). `#[serial]`.
      - `route_two_slice_with_precedence_llm_proceeds_when_no_existing_marker` — pre-populate file without marker (legacy file); call with `source="llm"`; assert proceeds with new marker prepended. `#[serial]`.
      - `route_two_slice_with_precedence_live_slice_unconditional` — verify the precedence guard applies to BOTH live + project slices uniformly (or document and test the per-slice policy explicitly). `#[serial]`.
      - `parse_precedence_marker_handles_malformed_marker_gracefully` — unit test of the parser helper.

      Helper test for the marker round-trip:
      - `precedence_marker_round_trip_preserves_body_bytes` — `prepend_precedence_marker` + strip → original body byte-equal.

      Symmetry test:
      - `route_inbound_commune_body_uses_shared_resolver` — assert (via behavior, e.g. setting up info.json that the OUTBOUND build_current_context_blocks resolves the same way) that wrapper + outbound + echo_commune all produce the same resolved project name for the same self_id. `#[serial]`.
    </action>
    <verify>
      <automated>cargo test --release route_inbound_commune_body 2>&1 | Select-Object -Last 30; cargo test --release route_two_slice_with_precedence 2>&1 | Select-Object -Last 30; cargo test --release precedence_marker 2>&1 | Select-Object -Last 30</automated>
    </verify>
    <done>
      - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`, `pub(crate)`.
      - `route_two_slice_outcome` returns granular per-slice state (cycle-3 HIGH 1).
      - `route_two_slice_with_precedence` applies precedence-metadata mitigation (cycle-3 HIGH 2).
      - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`, returns `RouteOutcome`, unit-testable without LLM mocking.
      - `process_file_drop` "commune" arm calls the seam BEFORE LLM handoff. Logs granular state (NOT boolean conflation).
      - Outbound `run_echo_commune` rewired to use `route_two_slice_with_precedence(..., "llm")`.
      - Inbound wrapper-direct uses `route_two_slice_with_precedence(..., "direct")` (unconditional, source of truth).
      - Project resolution in `route_two_slice_outcome` uses `crate::common::owlery::resolve_self_project_name_via_info_cwd` (cycle-3 HIGH 3 shared helper).
      - Route IO errors are structured WARN logs (operator-visible).
      - 4 granular-outcome tests + 6 precedence-mitigation tests + 1 marker round-trip + 1 symmetry test pass.
      - "signoff" arm unchanged.
    </done>
  </task>

  <task type="auto">
    <name>Task 3: 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:

      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"
      ```
      Expected: first line is `<!-- spt:source=direct spt:routed_at=<ISO> -->`; subsequent lines contain `marker-25.3-04-Z`.

      AND the structured log line appears in `todlando.log`:
      ```powershell
      Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
      ```

      Step 6 — Precedence-metadata mitigation smoke (cycle-3 HIGH 2): wait for the next echo_commune fire (within 60s of the direct route) and confirm:
      - The marker `marker-25.3-04-Z` is STILL in the project file.
      - If the haiku emitted a `<project-context>` body that would have erased the marker, the file contents are UNCHANGED and the `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line appears.
      - Wait further (>60s after the direct route) and trigger another echo_commune fire; this time the LLM emit proceeds (legitimate summarization wins after the protection window).

      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.
      - Precedence-metadata marker present at file head (`<!-- spt:source=direct ... -->`).
      - `[FILE-DROP] route_inbound_commune_body for todlando ...project=written` log line confirmed for the inbound.
      - Precedence-metadata mitigation verified: within protection window, marker preserved + `[ROUTE-GUARD] stale llm overwrite suppressed` log line appears. After window, LLM emit proceeds.
      - No encoding artifacts in the persisted body.
    </done>
  </task>

  <task type="auto">
    <name>Task 4: 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 `outcome.live == SliceWriteState::Written` AND `outcome.project == SliceWriteState::Written` (cycle-3 HIGH 1 — no false-positive boolean).
      - Assert ALL FOUR invariants:
        (a) NO `"tracked"` project anywhere — no `projects/tracked/` directory, no `project="tracked"` literal in any persisted file. (Defect B2 invariant.)
        (b) NO TRANSPORT-ENCODED ENVELOPE TAGS in persisted files — `projects/test_project/test-agent.md` contains LITERAL `project-marker-bbb` (no `&lt;EVENT`, `&lt;project-context`, `&lt;br&gt;`). (Defect C cycle-3 reframed 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.)
        (d) Precedence-metadata marker at file head — both files have first line matching `<!-- spt:source=direct spt:routed_at=.+ -->`. (Cycle-3 HIGH 2 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 with all cycle-3 mitigations active.
    </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 transport-encoded envelope tags, correct marker placement, AND precedence-metadata marker present.
      - `outcome.live == Written` AND `outcome.project == Written` (granular, cycle-3 HIGH 1).
      - `#[serial]`-annotated.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <what-built>
      Phase 25.3 complete. All four defects addressed in reordered execution + all cycle-3 mitigations:
      - Plan 01 (B2): project resolver correctness via info.json `cwd` field; belt-and-braces `tracked` filter at all three layers including context-sensitized legacy-perch (cycle-3 MEDIUM); shared resolver in `src/common/owlery.rs` (cycle-3 HIGH 3).
      - Plan 03 (C): encoding contract uniformity; `event_body_unescape` inverse; chunk-boundary tests; cycle-3 HIGH reframed entity invariant; dual-`<br>` round-trip tests; Option A LOCKED.
      - Plan 02 (D): psyche.md absorption + emission teaching (Option A no-name shape; 'SPT routes' phrasing); static prompt-contract tests for psyche.md + commune.md + signoff/SKILL.md (cycle-3 MEDIUM extension).
      - Plan 04 (A, this plan): wrapper-direct inbound routing (Option 1 LOCKED) via `route_inbound_commune_body` seam; granular `RouteOutcome` enum (cycle-3 HIGH 1 — no false `project_routed`); precedence-metadata stale-overwrite mitigation (cycle-3 HIGH 2 — replaces strict-subset); shared resolver consumed from `src/common/owlery.rs` (cycle-3 HIGH 3); structured failure logs.
      - Cross-plan: phase-level invariant test (Task 4) jointly validates all four defects + cycle-3 mitigations.

      Net observable outcome: `<project-context>` envelopes sent to a live agent persist SYNCHRONOUSLY to `projects/<resolved_project>/<self_id>.md` on disk (with precedence marker at file head), with stale-overwrite protection against subsequent LLM drops within the 60s protection window.
    </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 3 Step 4
         ```
      2. Within SECONDS, confirm marker in:
         ```powershell
         Get-Content "$env:LOCALAPPDATA\spt\psyches\tracked\projects\claude_skill_owl\todlando.md"
         ```
         Expected: first line is `<!-- spt:source=direct spt:routed_at=... -->`; body contains `final-verify-marker-25.3`.
      3. Confirm structured log line:
         ```powershell
         Select-String -Path "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Pattern '\[FILE-DROP\] route_inbound_commune_body for todlando .*project=written'
         ```
      4. Wait for next echo_commune fire within 60s; confirm marker preserved AND (if LLM emit drops the marker) `[ROUTE-GUARD] stale llm overwrite suppressed for todlando` log line.
      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_with_precedence writer (Option 1) | Wrapper-side direct write to `projects/<resolved_project>/<self_id>.md` with precedence marker at file head. |
  | Precedence-metadata marker comparison | Reads existing on-disk marker; parses source + routed_at; compares against protection window. |

  ## 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 (shared helper from owlery.rs), never from envelope body. Body content is written ONLY as file contents (not paths). `route_two_slice_outcome` 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 | PRECEDENCE-METADATA MITIGATION (cycle-3 HIGH 2): `route_two_slice_with_precedence` applies the marker-comparison guard on the OUTBOUND echo_commune emit path (`source="llm"`). If existing marker is `source=direct` within 60s, LLM write is SUPPRESSED + `[ROUTE-GUARD]` log surfaces. Inbound direct-route (`source="direct"`) remains unconditional (it IS the new source of truth). Replaces cycle-2 strict-subset heuristic (which over-rejected summarization). |
  | T-25.3.04-T3 | Tampering | Adversarial inbound envelope contains content designed to forge a `source=direct` marker | mitigate | The marker is APPLIED by SPT at write time — not read from envelope body. Adversarial body content containing `<!-- spt:source=direct ... -->` as user text is NOT trusted: SPT strips/replaces the head-marker on every write. If a body's first line LOOKS like a marker, it is treated as ordinary body content + a fresh SPT marker is prepended above it. Test: `precedence_marker_handles_user_body_with_fake_marker_at_head_gracefully`. |
  | T-25.3.04-T4 | Tampering | Marker timestamp manipulation (system clock skew or adversarial timestamp injection) | accept | Clock skew is a known systemic risk; the 60s window is generous. Adversarial timestamp injection is bounded by T-25.3.04-T3 (user body cannot supply markers). |
  | 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. Precedence-metadata 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 prefixes `[FILE-DROP]` and `[ROUTE-GUARD]` — operator-visible via `Select-String`. Granular `RouteOutcome` propagation (cycle-3 HIGH 1) ensures logs reflect actual per-slice state, not boolean conflations. |
  | T-25.3.04-SC | Tampering (supply chain) | n/a | n/a | No new dependencies. (chrono is already in Cargo.toml for timestamp helpers.) |
  </threat_model>

  <verification>
  - Task 1: Plan 03 body-state-at-process_file_drop confirmed DECODED; shared helper presence in owlery.rs confirmed.
  - Task 2 implements Option 1 with granular RouteOutcome + precedence-metadata mitigation. Shared resolver consumed from owlery.rs.
  - Task 3 deploys + smoke-verifies end-to-end (PowerShell only — no Bash heredocs).
  - Task 4 phase-level invariant test asserts joint resolution of all four defects + cycle-3 invariants (precedence marker present, granular outcomes).
  - Task 5 operator-verify checkpoint confirms observable outcome.
  - No regressions on existing wrapper tests (file_drop_handler_tests suite) or existing route_two_slice tests.
  - Phase 25.3 ROADMAP entry updated to reflect completion of all 4 plans.
  </verification>

  <success_criteria>
  - `RouteOutcome` + `SliceWriteState` enums exist in `src/owl/echo_commune.rs`; `route_inbound_commune_body` returns granular state (cycle-3 HIGH 1).
  - `route_two_slice_with_precedence` exists with marker-comparison guard (cycle-3 HIGH 2).
  - `route_inbound_commune_body` seam exists in `src/live/wrapper/mod.rs`; unit-testable without LLM mocking.
  - `process_file_drop` "commune" arm routes BEFORE LLM handoff; logs granular state.
  - Shared resolver `crate::common::owlery::resolve_self_project_name_via_info_cwd` consumed throughout (cycle-3 HIGH 3).
  - Outbound `run_echo_commune` uses `route_two_slice_with_precedence(..., "llm")`.
  - End-to-end: inbound `<project-context>` envelope → marker text in `projects/<name>/<self_id>.md` within SECONDS with precedence-metadata marker at file head.
  - Stale-overwrite mitigation verified: LLM emit within 60s of direct route is SUPPRESSED; LLM emit after 60s proceeds.
  - Phase-level invariant test passes (joint resolution of all 4 defects + cycle-3 invariants).
  - All PowerShell — no Bash heredocs.
  - 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:
  - Plan 03 body-state-at-process_file_drop confirmation (DECODED per Option A LOCKED).
  - Shared-helper presence confirmation (`pub(crate) fn resolve_self_project_name_via_info_cwd` in owlery.rs).
  - Diff summary for `src/live/wrapper/mod.rs` (extracted seam + wired call with granular-state log).
  - Diff summary for `src/owl/echo_commune.rs` (RouteOutcome + SliceWriteState enums + route_two_slice_outcome + route_two_slice_with_precedence; outbound rewire).
  - End-to-end smoke trace (PowerShell output of inbound delivery + file content showing precedence marker at head + `marker-25.3-04-Z`).
  - Precedence-metadata mitigation smoke verification (within-window suppression + outside-window proceed).
  - Phase-level invariant test pass evidence.
  - Codex cycle-2 HIGH/MEDIUM/LOW resolution table: each → task → verification.
    - HIGH 1 (false project_routed) → Task 2 enums + outcome propagation → granular-outcome tests.
    - HIGH 2 (monotonic guard underspecified) → Task 2 precedence-metadata variant → precedence-mitigation tests.
    - HIGH 3 (resolver helper placement) → resolved by Plan 01 cycle-3 placement → Task 1 grep gate.
    - MEDIUM (files_modified omits echo_commune.rs) → CORRECTED in manifest.
    - MEDIUM (file-path correction route_two_slice in echo_commune.rs) → CORRECTED throughout.
  - 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 persist synchronously with precedence-metadata stale-overwrite protection).
  - Final commit SHA list for the phase.
  </output>
  </content>
  </invoke>
  ---


  ## Review Instructions

  **Goal: convergence check.** For each cycle-2 HIGH, state RESOLVED / PARTIAL / UNRESOLVED with one-line evidence.
  Then identify any NEW HIGH issues introduced by the cycle-3 replan.
  Use severity markers: `- **HIGH:** ...`, `- **MEDIUM:** ...`, `- **LOW:** ...`

  End with: **CONVERGED** (no remaining HIGHs) OR **NOT CONVERGED** + list of blocking HIGHs.


