# threads

id: 019e3521-b5f2-77f3-acbe-4af1576944d5
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-update
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 4b1d367f926b5eb85c57db710abbc816cc532aec
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-update
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-update
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\17\rollout-2026-05-17T01-51-02-019e3521-b5f2-77f3-acbe-4af1576944d5.jsonl


id: 019e3527-8179-72a0-a9e5-e87cf341ab9b
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-execute-phase 06.7
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 4b1d367f926b5eb85c57db710abbc816cc532aec
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-execute-phase 06.7
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-execute-phase 06.7
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\17\rollout-2026-05-17T01-57-22-019e3527-8179-72a0-a9e5-e87cf341ab9b.jsonl


id: 019e35a5-d733-7e91-a585-a286eb80f2a0
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: f522868d791a8ab696f5a19e72621ebfff9e1916
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.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\17\rollout-2026-05-17T04-15-21-019e35a5-d733-7e91-a585-a286eb80f2a0.jsonl

title:
  # Cross-AI Plan Review Request — REBNO Phase 06.7 Gap-Closure Plans 05/06/07

  You are reviewing THREE gap-closure plans for Phase 06.7 of REBNO (a Chrome rebuild of an early-2000s GameMaker 5.3a multiplayer game).

  The phase's first 4 plans (01-04) shipped a CLIENT-AUTHORITATIVE movement model: clients send `position_update` intents at 30 Hz; server stores/broadcasts without semantic validation. Phases 01-04 were merged at HEAD 9816275. Operator HUMAN-UAT then found 2 blocker-class issues + 1 minor issue. Plans 05/06/07 close those gaps.

  Focus your review on whether Plans 05/06/07 actually close the gaps without introducing new defects, scope creep, or coverage holes. Plans 01-04 are out of scope (already executed).

  ## Project Context (CLAUDE.md hard rules + extracted constants)

  # CLAUDE.md — REBNO Project Instructions

  ## Project

  **REBNO** — ground-up rebuild of *BN Online* (a late-1990s/early-2000s GameMaker 5.3a multiplayer game) on a modern web stack. Target: Chrome users walk around and chat in real time, then full feature parity with the original.

  **Core Value:** Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.

  ## Source-of-Truth Files

  Everything depends on two files in `legacy/open-source-release/`:

  - `BN Online Client 5-8.gmd` — latest client revision
  - `BN Online Master 5-4.gmd` — latest server revision

  Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only. Do not target them as primary inputs.

  ## Repo Layout

  | Path | Purpose |
  |---|---|
  | `decomp/wiki/` | 17-doc reverse-engineering knowledge base for GameMaker 5.3a — read these before reasoning about extraction, the `.gmd` format, the 39dll wire protocol, or `.bno`/`.bnb`/`.bnu` save data |
  | `legacy/open-source-release/` | Authoritative source `.gmd` files (the two listed above) |
  | `legacy/source-archive/`, `legacy/servers/` | Older revisions — reference only |
  | `legacy/unity-project/`, `legacy/maya-project/` | Prior abandoned remake attempts and original Maya assets — informative, not authoritative |
  | `legacy/audio/`, `legacy/open-source-release-extras/` | Original audio + assorted artifacts |
  | `.planning/` | GSD workflow state — see below |
  | `.planning/codebase/` | 7 docs mapping the existing brownfield codebase |
  | `.planning/research/` | 5 docs of project-level research (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY) |

  ## Tech Stack (decided)

  - **Server**: Node 22 + TypeScript + Colyseus 0.17 + `ws` + Better-Auth + argon2id + better-sqlite3 + Litestream
  - **Client**: TypeScript + Vite — engine = Phaser 3 (preferred) OR PixiJS (fallback). Final choice locked by ADR at end of Phase 2.
  - **Transport**: WebSocket binary frames; authoritative server; clients send intent
  - **Persistence**: SQLite + Litestream → Tigris bucket on Fly.io. Postgres only if Phase 3 surfaces a relational requirement.
  - **Hosting**: Fly.io single machine, persistent volume, <50 CCU target
  - **Monorepo**: pnpm workspaces. Shared packages: `protocol` (wire types + codec) and `game-logic` (pure deterministic simulation, runs in Node + browser identically).

  See `.planning/research/STACK.md` for version pins, alternatives matrix, and Fly.io specifics.

  ## Extracted Constants (load-bearing — never assume otherwise)

  | Constant | Value | Source of truth |
  |---|---|---|
  | Floor tile dimensions | **44 × 40 px** (W × H) | `extracted/client-5-8/sprites/0023-Tile1/meta.json` |
  | Room tick rate | 30 steps/sec | `extracted/client-5-8/rooms/*/meta.json` `speed: 30` |
  | View / camera viewport | 640 × 480 px | `extracted/client-5-8/rooms/*/meta.json` `views[0].viewW/viewH` |
  | BNCentral room dimensions | 8000 × 6400 px | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` |
  | MVP synthetic room (20×20 tiles) | 880 × 800 px | derived: 20·44 × 20·40 |

  **NEVER hard-code 32×32 or any other tile size.** GameMaker's editor defaults are misleading; BNO's tile pitch is 44×40. Every renderer, collision grid, asset-pipeline atlas slicer, and test-room generator must read these from extracted `meta.json` or use the constants above. See [`docs/extracted-engine/scene-room-model.md`](docs/extracted-engine/scene-room-model.md) "Canonical floor tile" for full derivation.

  ## Coordinate Conventions (D-63 — Plan 06.4)

  The legacy GameMaker engine and our Phaser renderer use different sprite-origin
  conventions. Player-attached effects (TeleIn, TeleOut, HexportIn/Out, ncol*,
  jokershell, watching) must convert between them.

  | Engine | Navi sprite origin | Effect placement |
  |--------|---------------------|------------------|
  | Legacy GM (5.3a) | `(0, 0)` = top-left (head) | effect drawn at legacy `(x, y)` = Navi top-left |
  | Phaser (REBNO)   | `(0.5, 1)` = bottom-center (feet) | sprite.x = Navi center, sprite.y = visible feet line (NOT sprite-rect bottom) |

  **Convention shift pin:**
  - `legacy_x = phaser_x - NAVI_WIDTH_PX/2 = phaser_x - 18`
  - `legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y = phaser_y - 46`

  `NAVI_VISIBLE_FEET_Y = 46` is the bbox-bottom of NaviStandD, NOT 48 (sprite-rect
  height). The two-pixel difference (rows 46-47 in the sprite rect) is the
  transparent foot-shadow region below the visible feet — including those rows
  in the conversion places effects two pixels too low (operator UAT 2026-05-14).

  **D-55c round-2 (06.4 round-2 UAT 2026-05-15 on commit 63b022f) effect-nudge:**
  `EFFECT_NUDGE_X_PX = -1`, `EFFECT_NUDGE_Y_PX = +2` are added inside
  `phaserOriginForLegacyPlayerAttached`. Phaser origin convention: increasing
  originX shifts the rendered sprite LEFT (`sprite-left = sprite.x - originX * width`);
  increasing originY shifts the rendered sprite UP. Cycle-5 set
  `EFFECT_NUDGE_X_PX = +1` (originX = 34/64) to correct a 1 px right deviation
  on staging 5fbb357; round-2 UAT on commit 63b022f reported TeleIn 2 px too
  LEFT — i.e., cycle-5 over-corrected. To shift the sprite 2 px RIGHT we
  DECREASE originX-numerator by 2: net `EFFECT_NUDGE_X_PX = -1`, originX = 32/64
  = 0.5. `EFFECT_NUDGE_Y_PX` stays at +2 (vertical good per operator).
  Round-2 TeleIn-variant origins are `[32/64, 90/100] = [0.5, 0.9]`. The bare
  `NAVI_WIDTH_PX = 36` / `NAVI_VISIBLE_FEET_Y = 46` constants stay anchored to
  the extracted meta.json — drift detection against
  `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` continues to assert
  those source-of-truth values.

  **Canonical entry:** `apps/client/src/render/legacy-origin.ts` exports
  `phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]`.
  All player-attached effect ports MUST use this helper. Inline origin math in
  `apps/client/src/render/` is rejected by code review (HARD gate 5).

  **Source of truth:** `extracted/client-5-8/sprites/0000-NaviStandD/meta.json`
  (width=36, height=48, bboxBottom=46). The atlas-mvp.json frame metadata is
  asserted against these constants by the unit test
  `apps/client/src/__test__/legacy-origin.test.ts` (drift-detection — fails CI
  if atlas regeneration produces different Navi metrics).

  [doc->REQ-CLI-04] [doc->REQ-CLI-08]

  ## Hard Rules

  1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]
  2. **No faithful port of plaintext passwords.** Original `localList.txt` plaintext archive is a security incident, not a feature. argon2id from packet 1.
  3. **No faithful port of "run clipboard as superuser" admin.** Original RCE-as-a-feature admin model is replaced by a separate authenticated web UI in Phase 7.
  4. **`.bno` / `.bnb` / `.bnu` parsing requires extracted GML first.** These formats only exist as the call order of `file_bin_*` ops in the project's own GML. Do NOT hex-inspect; extract Phase 1, derive Phase 3, parse Phase 4+.
  5. **39dll wire protocol = call order.** Same principle. Reverse it from `write*`/`read*` sequences in extracted Master GML, not from packet captures.
  6. **Extract → document → rewrite, in that order.** Phases 1-3 produce schemas; Phases 4+ consume them. No new TypeScript before Phase 4.
  7. **Modern decompilers (UTMT, Altar.NET) cannot read GM 5.3a.** Use era-appropriate tools: GM Decompiler v2.1, GMD-Recovery, LateralGM, in a WinXP VM. See `decomp/wiki/13-modern-tool-incompat.md`.
  8. **Repo stays private through Phase 7.** BNO is Capcom-derived (Mega Man Battle Network). Legal/IP path deferred. Verify `legacy/` is not pushed publicly until cleared.

  ## GSD Workflow

  This project uses the GSD (Get Shit Done) phased-execution system. Workflow state lives in `.planning/`:

  - `PROJECT.md` — project context, requirements, constraints, decisions (read first)
  - `REQUIREMENTS.md` — 49 v1 requirements with REQ-IDs, traceability table
  - `ROADMAP.md` — 7 phases with goals, requirement mapping, success criteria
  - `STATE.md` — current phase + status (read to know where work is)
  - `config.json` — workflow preferences (mode=yolo, granularity=standard, parallelization=on, model_profile=quality, research=on, plan_check=on, verifier=on)
  - `research/` — project-level research outputs (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY)
  - `codebase/` — codebase map (STACK, INTEGRATIONS, ARCHITECTURE, STRUCTURE, CONVENTIONS, TESTING, CONCERNS)

  ### Workflow Discipline

  - **Per-phase flow**: `/gsd-discuss-phase N` → `/gsd-plan-phase N` → `/gsd-execute-phase N` → `/gsd-verify-work N`
  - **Atomic commits**: Each phase commits its artifacts immediately. Use `gsd-sdk query commit "msg" <files>`.
  - **Phases 1-3 do NOT parallelize with Phase 4+.** Within a phase, parallel plan execution is fine.
  - **Hard milestone**: CLI-08 in Phase 6 — two players move + chat over deployed server. Entire rebuild gates here.
  - **Research-flagged phases** (1, 3, 7) need deeper per-phase research at planning time.

  ## Phase Roadmap (high level)

  1. **Extraction** — both `.gmd` → diffable per-resource trees (8 reqs)
  2. **Client Engine Documentation** — document client engine + Phaser/Pixi ADR (4 reqs)
  3. **Server Documentation & Schemas** — reverse 39dll, save formats, persistence ADR, closed parity checklist (6 reqs)
  4. **Server Rebuild (MVP)** — Node + Colyseus authoritative server for movement+chat slice (12 reqs)
  5. **Deploy** — Fly.io + Litestream + CI/CD + restore runbook (8 reqs)

  ## UAT Findings (06.7-UAT.md)
  ---
  status: diagnosed
  phase: 06.7-network-model-client-trust-fall-trigger
  source: [06.7-01-SUMMARY.md, 06.7-02-SUMMARY.md, 06.7-03-SUMMARY.md, 06.7-04-SUMMARY.md, 06.7-HUMAN-UAT.md]
  started: 2026-05-17T10:18:59Z
  updated: 2026-05-17T10:34:30Z
  ---

  ## Current Test

  [testing complete]

  ## Tests

  ### 1. Cold Start Smoke Test
  expected: Kill running server/client. Start fresh. Server boots clean, client handshake succeeds at PROTOCOL_VERSION=4, join BNCentral returns live state.
  result: pass
  note: Operator connected two clients to staging successfully; cold-start path implicitly validated by subsequent multiplayer test.

  ### 2. Diagonal-stop drift bug closed (D-02a)
  expected: Log in to BNCentral spawn. Hold W+D for 2 s for up-right diagonal. Release both keys simultaneously. Sprite stops at its current pixel position with no horizontal shift and no ~10 px snap right or left.
  result: pass
  reported: "diagonal stop drift fixed"

  ### 3. Dropped-packet hitching closed (D-02b)
  expected: DevTools → Network → Throttling = Slow 3G (or custom ~5% loss / 150 ms RTT). Hold W to run north 5 s. Continuous smooth movement.
  result: pass
  reported: "no more dropped-packet hitching, silky smooth for local movement"

  ### 4. Two-player smoke (REQ-CLI-08 hard milestone regression)
  expected: Operator A + B in two browsers join BNCentral. Each sees the other's sprite and smooth motion (no rubber-band, no drift), running animation plays, velocity is applied. Chat appears with correct origin.
  result: issue
  reported: "client of 2nd joiner doesn't see any remote player position or sprite updates; client of 1st joiner does see player position updates, but they're super slow, inconsistent timing, facing direction comes through but never sprite change (running anim never plays), velocity is never applied; chat works"
  severity: blocker

  ### 5. PROTOCOL_VERSION 3 client rejection — AUTOMATED COVERAGE (D-12)
  expected: `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` exits 0. v3 rejected with close-code 4400 / PROTOCOL_VERSION_MISMATCH; v4 accepted.
  result: pass
  note: First run produced 524≠4400 + 'seat reservation expired' failures — diagnosed as stale packages/protocol build artifact. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275 (3.34s, 0 failures). Server correctly throws ServerError(4400, "PROTOCOL_VERSION_MISMATCH ...") at apps/server/src/RebnoRoom.ts:394-398.

  ### 6. Rollback drill (D-14)
  expected: Confirm documented rollback target references the four Phase 06.7 plan commits.
  result: pass
  note: Verified in 06.7-04-SUMMARY.md "Rollback Target" section — lists Plan 01 (1ba1fe6), Plan 01 summary (086bf71), Plan 02 (f9b02e3), Plan 03 (8ba0f9b), Plan 04 docs commit. Four production commits accounted for.

  ### 7. Idle-cadence steady 30 Hz network confirm (D-06)
  expected: Idle BNCentral 5 s with WS frame panel open. ~150 position_update frames (120–180 range), each with vx=0/vy=0, no gaps >100 ms.
  result: skipped
  reason: Operator did not run DevTools WS frame measurement during this UAT pass. Automated coverage in apps/client/src/__test__/position-dispatcher.test.ts is the standing guard; manual measurement deferred until Test 4 blocker resolved (remote-sync issues may also indicate dispatcher cadence problems).

  ## Summary

  total: 7
  passed: 4
  issues: 1
  pending: 0
  skipped: 1
  blocked: 0
  new_regressions: 1
  gaps_diagnosed: 5

  ## Gaps

  - truth: "Each peer sees the other's player position, velocity, facing, and animation state in near-real-time"
    status: failed
    reason: "User reported: client of 2nd joiner doesn't see ANY remote player position or sprite updates from 1st; 1st sees super slow inconsistent updates from 2nd"
    severity: blocker
    test: 4
    root_cause: "RC #1 — Wire-schema vs runtime-timestamp mismatch. cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) declares monotonic_at_ms as .int(); PositionDispatcher passes Phaser fractional rAF _time arg unmodified (position-dispatcher.ts:30-46, GameScene.ts:1193 sendTick(_time)). Every frame fails zod parse → server logs invalid_intent and silently drops (onMessageHandlers.ts:315-324). Only frames landing on integer-ms boundaries pass — hence super-slow inconsistent timing. For 2nd joiner viewing 1st (mostly idle pre-join), nearly all updates dropped → sprite frozen at spawn → 'sees nothing'."
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 30-46 pass monotonic_at_ms through unmodified, unlike x/y/vx/vy which ARE rounded"
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Line 1193 passes Phaser fractional _time to sendTick"
      - path: "packages/protocol/src/intents.ts"
        issue: "Lines 125-137 .int() constraint on monotonic_at_ms (correct — wire contract)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        issue: "Unit tests use integer time args (sendTick(1), sendTick(1234)) — masked runtime drift from CI"
    missing:
      - "Apply Math.floor(monotonic_at_ms) at position-dispatcher.ts:42 (preserve u32-int wire contract)"
      - "Add regression test: dispatcher.sendTick(1234.567) → payload monotonic_at_ms === 1234"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player velocity (vx, vy) is applied to position interpolation/extrapolation"
    status: failed
    reason: "User reported: velocity is never applied"
    severity: blocker
    test: 4
    root_cause: "RC #2 — PredictionEngine.getLocalState() always returns vx=0, vy=0. step.ts:243-251 (with-input branch) hardcodes vx:0, vy:0 per BNO instant-set model (ACCEL=0, FRICTION=0). predictor.ts:113-114 propagates to localState. position-dispatcher.ts:37-38 Math.round(local.vx/vy) → always 0 on wire. Server echoes 0; remote renderer never animates."
    artifacts:
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 243-251 with-input branch returns vx:0, vy:0 unconditionally"
      - path: "apps/client/src/prediction/predictor.ts"
        issue: "Lines 113-114 propagate step's vx=0/vy=0 to localState"
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 37-38 read local.vx/vy which are always 0"
    missing:
      - "Inject axis-vector callback into PositionDispatcher (parallel to getFacing / getIsRunning)"
      - "Compute vx = round(axisX * RUN_SPEED_PX_PER_TICK), vy = round(axisY * RUN_SPEED_PX_PER_TICK) — mirror GameScene.ts:1199-1200 pattern used for local renderer's onSimulationTickLocal"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player sprite plays running animation when vx/vy != 0"
    status: failed
    reason: "User reported: facing comes through but sprite change never happens, running anim never plays"
    severity: blocker
    test: 4
    root_cause: "RC #3 — Remote-render reads vx/vy only, ignores broadcast anim_state. GameScene.ts:1213-1226 remote-loop calls playerRenderer.onSimulationTickRemote(sessionId, p.vx, p.vy, p.x, p.y); never consumes p.anim_state. PlayerRenderer.ts:463-470 derives isRunning = hypot(vx,vy) > 0 — with vx=0/vy=0 from RC #2, always Stand frame. Dispatcher DOES author anim_state correctly via packAnimState(facing, getIsRunning()) and PlayerState.anim_state IS @type-decorated; consumer just ignores it."
    artifacts:
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Lines 1213-1226 remote-sim-tick loop ignores p.anim_state"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        issue: "Lines 463-470 derive isRunning from vx/vy instead of consuming explicit anim_state"
    missing:
      - "Pass unpackAnimState(p.anim_state).running as new arg to onSimulationTickRemote (principled — server anim_state is single source of truth)"
      - "OR rely on RC #2 fix making vx/vy non-zero during movement (cheaper consequence of RC #2)"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline"
    status: failed
    reason: "User reported: right-edge and bottom-edge floor collision both stop one pixel farther away (on the respective axis) than before"
    severity: minor
    test: 4
    root_cause: "Boundary-convention mismatch between two collision paths in packages/game-logic/src/collision.ts. Client-side walkable-grid path (isFeetBboxWalkable line 172) treats feet-bbox right/bottom corner pixel as INCLUSIVE via Math.floor(px/tile_w); legacy polys path (feetBboxIntersectsAABB lines 69-72) treats it as EXCLUSIVE via strict feetMaxX > minX. The two paths produce stops differing by exactly 1 px on MAX axes (right, bottom) and agree on MIN axes (left, top) — matches operator report exactly. Pre-existed since Plan 06.1-02 (0c40432) but was masked: pre-06.7 ReconcileEngine.onServerSnapshot called predictor.applyServerSnapshot for self-player, replacing predicted position with server polys-resolved position. Phase 06.7 Plan 03 (8ba0f9b) added early-return for self-player in reconciler.ts:53-54 — removed corrective sink, exposed grid-path 1-px-short stop."
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        issue: "Line 172 — Math.floor in isFeetBboxWalkable causes inclusive-corner asymmetry (load-bearing line)"
      - path: "packages/game-logic/src/collision.ts"
        issue: "Lines 63-74 — feetBboxIntersectsAABB uses strict inequality (other half of mismatch)"
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 179-225 — branch selector routes client to grid path"
      - path: "apps/client/src/prediction/reconciler.ts"
        issue: "Lines 53-54 — isSelfPlayer early-return that exposed pre-existing asymmetry"
    missing:
      - "Normalize boundary conventions: make isFeetBboxWalkable's right/bottom corner sample exclusive of bbox boundary"
      - "Subtract 1 from right/bottom probe pixels at call sites step.ts:194-195 and step.ts:214-215 (intent explicit there); OR adjust inside isFeetBboxWalkable for right/bottom only"
      - "Add regression test pinning right-edge AND bottom-edge stop pixels against tile-boundary wall for known walkable grid"
    debug_session: ".planning/debug/edge-collision-off-by-one.md"

  ## Resolved (false positive)

  - truth: "PROTOCOL_VERSION 3 client rejection emits close-code 4400 with PROTOCOL_VERSION_MISMATCH message"
    status: resolved_no_fix
    reason: "Initial run reported AssertionError: expected 524 to be 4400. Diagnosed as stale packages/protocol build. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275. Server correctly throws ServerError(4400, 'PROTOCOL_VERSION_MISMATCH ...') at apps/server/src/RebnoRoom.ts:394-398; @colyseus/ws-transport preserves 4400 through to SDK MatchMakeError.code."
    severity: n/a
    test: 5
    debug_session: ".planning/debug/v3-rejection-wrong-close-code.md"
    note: "Operator process improvement: ensure pnpm -C packages/protocol build runs before integ tests after any protocol schema/version change."

  ## Debug Session 1 — two-player-remote-sync-broken.md
  ---
  status: diagnosed
  trigger: "Two-player smoke test FAILED post Phase 06.7 client-trust flip — 2nd joiner sees nothing from 1st; 1st sees 2nd's updates super slow with no anim change and no velocity applied"
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Three layered root causes — (#1) monotonic_at_ms .int() vs fractional Phaser timestamp rejects most frames; (#2) PredictionEngine always returns vx=0/vy=0 due to BNO instant-set model in step(); (#3) remote render ignores broadcast anim_state and re-derives anim from vx/vy (which are 0 from #2)
  test: Static code analysis of server handler + client dispatcher + remote-render + step()
  expecting: All three are visible in current code; tests do not catch them because mocks bypass each defect
  next_action: Return diagnosis to caller (find_root_cause_only mode)

  ## Symptoms

  expected: Two players in BNCentral both see each other's sprite positions, velocity-driven movement, running animation when other moves; chat works for both
  actual:
    - 2nd joiner sees NOTHING of 1st joiner's movement/sprite (sprite may not even be visible)
    - 1st joiner sees 2nd's updates SLOWLY with inconsistent timing
    - 1st joiner: facing updates work, but sprite never animates (running anim doesn't play)
    - 1st joiner: velocity (vx, vy) is never applied to remote sprite rendering
    - Chat works both directions (so connection + broadcast generally healthy)
  errors: None (no console errors, no test errors — purely operator observation)
  reproduction: HUMAN-UAT Test 4. Two browsers, two accounts, join BNCentral, walk W/D/S/A square, each observes other
  started: After Phase 06.7 client-trust flip (plans 02 + 03). Pre-06.7 worked.

  ## Eliminated

  - hypothesis: PlayerState fields not @type-decorated for sync (so anim_state / sprite_override never reach peers)
    evidence: packages/protocol/src/state.ts:57-58 — both fields ARE @type('number') / @type('string'). Colyseus state-sync will broadcast them.
    timestamp: investigation

  - hypothesis: onJoin fails to deliver existing-player state to late-joiner
    evidence: Colyseus 0.17 delivers initial state-sync containing all entries in state.players to a new client automatically (no app code needed). The onMessageHandlers PlayerState fields are server-set in onJoin BEFORE state.players.set, so a late-joiner WILL receive 1st-joiner's PlayerState in their initial state-sync.
    timestamp: investigation

  - hypothesis: PositionDispatcher never fires for 2nd joiner (dispatcher not attached)
    evidence: GameScene.onLocalJoin (apps/client/src/scenes/GameScene.ts:676) creates the dispatcher when `!this.positionDispatcher && this.room && this.prediction`. Local onLocalJoin runs unconditionally; dispatcher is wired before update() loop calls sendTick.
    timestamp: investigation

  - hypothesis: Colyseus $(player).onChange() doesn't register correctly during initial state-sync
    evidence: bindHandlers in colyseus-client.ts:251 registers $(player).onChange synchronously inside players.onAdd; per Colyseus 0.17 docs onAdd auto-fires for existing items, registering listeners.
    timestamp: investigation

  - hypothesis: server `applyToColyseusState` overwrites client-written PlayerState fields
    evidence: packages/game-logic/src/step.ts:68-83 — when input is undefined (always the case in RebnoRoom.tickLoop now), step() preserves x/y/vx/vy/last_input_seq exactly. applyToColyseusState rewrites those identical values — no overwrite.
    timestamp: investigation

  ## Evidence

  - timestamp: investigation
    checked: packages/protocol/src/intents.ts line 125-137 (cPositionUpdateSchema definition)
    found: monotonic_at_ms uses `.finite().int().min(0).max(0xffffffff)` — STRICT integer required. Plan 01-SUMMARY notes this was deliberate ("closes codex review concern #7"). x/y/vx/vy all `.int()` too.
    implication: Any fractional monotonic_at_ms causes zod parse failure → server logs `invalid_intent` and DROPS the frame.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:30-46 (PositionDispatcher.sendTick)
    found: `monotonic_at_ms` is passed through to the payload UNMODIFIED. x/y/vx/vy are Math.round'd. monotonic_at_ms is NOT rounded.
    implication: Whatever the caller passes goes directly to the wire.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1193 (sim-tick call to sendTick)
    found: `this.positionDispatcher?.sendTick(_time)` — passes Phaser's `_time` (the first arg to `update(_time, dt)`), which is the rAF/performance.now-derived high-resolution timestamp. In modern browsers this is fractional ms (e.g. 1234.567).
    implication: Every position_update frame fails `monotonic_at_ms.int()` UNLESS the browser returns an integer ms (rare modes: 1ms-clamped non-cross-origin contexts, throttled background tabs). Hence "super slow with inconsistent timing" — only the occasional integer-tagged frame is accepted.

  - timestamp: investigation
    checked: apps/client/src/__test__/position-dispatcher.test.ts
    found: All unit tests call `dispatcher.sendTick(1)`, `sendTick(2)`, `sendTick(1000)`, `sendTick(1234)` — INTEGER arguments. Tests never exercise the realistic fractional-timestamp path.
    implication: The defect ships green-tested. Server handler integration tests also use synthetic integer timestamps. Drift between test fixture and runtime call-site.

  - timestamp: investigation
    checked: packages/game-logic/src/step.ts:243-251 (with-input branch of step)
    found: After integrating displacement, `newPlayers.set(...)` writes `vx: 0, vy: 0` ALWAYS. Comment line 240-242: "BNO has no velocity accumulation (ACCEL=0, FRICTION=0 — instant set/stop). vx/vy are kept at 0 in the BNO-faithful model".
    implication: PredictionEngine.predictTick() consumes step() and assigns `this.localState = next.players.get(...)`. After any predict tick, localState.vx and localState.vy are 0. PositionDispatcher.sendTick reads `local.vx, local.vy` → always sends vx=0, vy=0 on the wire.

  - timestamp: investigation
    checked: apps/server/src/onMessageHandlers.ts:354-360 (position_update handler)
    found: When a frame DOES pass zod (rare due to issue #1), server writes `player.vx = parsed.data.vx` (=0) and `player.vy = parsed.data.vy` (=0). PlayerState.vx/vy stays at 0 indefinitely for every player.
    implication: Server's authoritative PlayerState.vx/vy is always 0. Colyseus state-sync broadcasts vx=0, vy=0 to every peer.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1213-1226 (sim-tick remote loop)
    found: For each non-local player in state.players, calls `playerRenderer.onSimulationTickRemote(sid, p.vx ?? 0, p.vy ?? 0, p.x ?? 0, p.y ?? 0)`. Does NOT consume `p.anim_state`. Comment line 1209-1212: "Remote players consume PlayerState.vx / .vy directly. Pre-06.7 derived velocity from held-axis fields, but server Path A no longer writes those fields; position_update writes vx/vy as px/tick."
    implication: Remote-render derives running flag from vx/vy. Both are always 0 (from issue #2). deriveFrameWithAuthoritativeFacing returns a Stand-* frame → running anim NEVER plays for any remote player. The broadcast PlayerState.anim_state (which IS correctly authored per-tick by the dispatcher's `packAnimState(facing, this.getIsRunning())` from input axes — bug-free at the dispatcher) is COMPLETELY UNUSED on the consumer side.

  - timestamp: investigation
    checked: apps/client/src/render/PlayerRenderer.ts:434-470 (onSimulationTickRemote internals)
    found: deriveFrameWithAuthoritativeFacing(renderVx, renderVy, ...) — when renderVx=0 and renderVy=0, isRunning=false → Stand frame. Confirmed by line 476 "isFirstRemote → frameKey published as 0000-NaviStandD_000-style key".
    implication: Even with correct broadcast anim_state, the renderer ignores it.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:38 (getIsRunning callback origin)
    found: GameScene.ts:681-685 builds getIsRunning from inputDispatcher.axisX()/axisY() ≠ 0. This correctly reports running based on user input — the running-flag bit in anim_state IS authored correctly. The dispatcher's anim_state byte on the wire is correct.
    implication: anim_state is correct end-to-end on the wire. The defect is that the consumer never reads it.

  ## Resolution

  root_cause:
    Three layered root causes account for ALL observed symptoms:

    #1 (PRIMARY — explains "super slow, inconsistent timing" + "2nd joiner sees nothing"):
    cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) requires
    `monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)` — strict integer.
    GameScene.ts:1193 calls `positionDispatcher?.sendTick(_time)` passing Phaser's `_time`
    (a fractional high-resolution timestamp from rAF). PositionDispatcher passes the value
    through unmodified to the wire payload. Server zod-parses → fails `.int()` → drops the
    frame with `invalid_intent` warn. Effectively 100% of position_updates are rejected
    except in rare browser modes where performance.now() returns 1-ms-clamped integers
    (background tabs, certain incognito modes). On staging this produces the
    "super slow, inconsistent timing" symptom. For the 2nd joiner viewing the 1st (who
    was at spawn and hadn't moved much before the 2nd joined), most/all updates are
    dropped → 1st's sprite appears frozen at spawn coords → "sees nothing of 1st".

    #2 (explains "velocity is never applied"):
    PredictionEngine.getLocalState() returns the post-predictTick `localState`, which is
    the `PlayerSim` returned by step(). Per packages/game-logic/src/step.ts:243-251
    (with-input branch) and BNO instant-set model, step() ALWAYS returns vx=0, vy=0.
    PositionDispatcher.sendTick (apps/client/src/prediction/position-dispatcher.ts:37-38)
    reads `local.vx, local.vy` from getLocalState → always sends vx=0, vy=0 on the wire.
    Server stores vx=0, vy=0 in PlayerState. Colyseus broadcasts vx=0, vy=0 to peers.

    #3 (explains "running animation never plays"):
    GameScene.ts:1213-1226 sim-tick remote loop reads `p.vx ?? 0, p.vy ?? 0` (which from
    #2 are always 0 even when the source position_update wasn't rejected) and ignores
    `p.anim_state` entirely. PlayerRenderer.onSimulationTickRemote derives the running
    flag from renderVx/renderVy → Stand frame always. The broadcast anim_state byte
    (which IS correctly authored by PositionDispatcher.sendTick line 40
    `packAnimState(facing, this.getIsRunning())`) is unused on the consumer side.

  fix:
    Fixes are NOT applied — caller requested find_root_cause_only.

    Suggested fix direction (for Plan 06.8 or 06.7-hotfix):

    Fix #1 — choose ONE:
      (a) Coerce monotonic_at_ms to integer at the dispatcher boundary:
          apps/client/src/prediction/position-dispatcher.ts line 42 →
          `monotonic_at_ms: Math.floor(monotonic_at_ms)`. Simplest, minimal-blast-radius.
      (b) Loosen the schema in packages/protocol/src/intents.ts line 135 to
          `z.number().finite().nonnegative().max(0xffffffff)` (drop `.int()`). Revisits
          codex review concern #7 — only acceptable if the u32 binary contract is
          relaxed for the JSON-over-msgpack wire path.
      Preferred: (a). Keeps the strict int() invariant in the protocol while making the
      actual call-site honor it. Add a regression test in position-dispatcher.test.ts that
      calls `dispatcher.sendTick(1234.567)` and asserts the payload's monotonic_at_ms is
      1234 (or 1235, whichever rounding is chosen).

    Fix #2: PositionDispatcher must send the wire vx/vy that represents CURRENT input
    intent, NOT the post-step PlayerSim's BNO-instant-set vx=0. Replace the
    `Math.round(local.vx) / Math.round(local.vy)` reads with the same intent-axes source
    as `getIsRunning` already uses:
      `vx: Math.round((this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK)`
      `vy: Math.round((this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK)`
    This will require injecting an axis-callback into PositionDispatcher (parallel to the
    existing `getFacing` and `getIsRunning` callbacks). Mirrors the pattern at
    GameScene.ts:1199-1200 for `onSimulationTickLocal`.

    Fix #3: Remote-render must consume anim_state (the canonical signal) instead of
    re-deriving from vx/vy. Either:
      (a) Decode p.anim_state with unpackAnimState(byte).running and pass it as an
          explicit `isRunning` arg to onSimulationTickRemote (new arg). Renderer uses the
          running flag directly instead of computing from vx/vy hypot.
      (b) Continue to consume vx/vy but ensure they correctly carry intent (after fix #2
          this becomes a non-issue because vx/vy will be non-zero during movement).
      Both are valid. (a) is more principled (single source of truth on the wire);
      (b) is a side-effect of fix #2 and may be sufficient by itself.

  verification: (not applied — diagnose only)
  files_changed: []

  ## Files Involved

  - packages/protocol/src/intents.ts:125-137 — cPositionUpdateSchema with .int() on monotonic_at_ms (root cause #1 location)
  - apps/client/src/prediction/position-dispatcher.ts:30-46 — sendTick passes fractional monotonic_at_ms through (root cause #1 propagation point); reads vx/vy from prediction (root cause #2)
  - apps/client/src/scenes/GameScene.ts:1193 — calls sendTick(_time) with fractional Phaser timestamp (root cause #1 call site)
  - apps/client/src/scenes/GameScene.ts:1213-1226 — remote sim-tick loop reads vx/vy only, ignores anim_state (root cause #3)
  - packages/game-logic/src/step.ts:243-251 — BNO instant-set model returns vx=0/vy=0 always (root cause #2 source)
  - apps/client/src/prediction/predictor.ts:113-114 — predictTick assigns localState from step's output, propagating vx=0/vy=0 (root cause #2 propagation)
  - apps/client/src/__test__/position-dispatcher.test.ts — unit tests use integer time args (1, 2, 3, 1000, 1234), masking root cause #1

  ## Debug Session 2 — edge-collision-off-by-one.md
  ---
  status: diagnosed
  trigger: "Right-edge AND bottom-edge floor collision now stop the local player ONE PIXEL FARTHER away from the wall than pre-06.7 baseline."
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Client-side `isFeetBboxWalkable` walkable-grid path treats the right/bottom corner sample as INCLUSIVE (via `Math.floor`), while the legacy server-side `feetBboxIntersectsAABB` polys path uses STRICT inequality. The two paths disagree by exactly 1 px on the max-axis edges only. Pre-06.7 the server-authoritative snapshot pulled the local sprite back to the polys-path position via reconcile→replay; in 06.7 the self-player reconciler became a no-op so the client's grid-path 1-px-short stop is now what the user sees.
  test: Trace exact stop pixel for a wall at tile boundary on each path.
  expecting: Grid path stops 1 px short of polys path on right + bottom edges only.
  next_action: Return diagnosis (diagnose-only mode).

  ## Symptoms

  expected: Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline
  actual: Sprite stops 1 px farther from wall on right/bottom edges only (left/top unchanged)
  errors: None
  reproduction: Walk right or down into BNCentral floor edge collision; compare to pre-06.7 baseline
  started: Discovered during 2026-05-17 operator UAT for Phase 06.7

  ## Eliminated

  (none — first hypothesis confirmed)

  ## Evidence

  - checked: git log on packages/game-logic/src/step.ts and collision.ts since pre-06.7
    found: Plan 02 (f9b02e3) added an `if (input === undefined) { passthrough }` short-circuit. No collision math change. collision.ts and per-axis loop unchanged since Plan 06.4 (3b696c6).
    implication: Collision arithmetic itself was identical before and after 06.7 — the off-by-one is not a math change. The change in EFFECTIVE final pixel for the local sprite must come from a removed corrective path elsewhere.

  - checked: apps/client/src/prediction/reconciler.ts
    found: `onServerSnapshot(snap, isSelfPlayer = false)` returns immediately when `isSelfPlayer === true` (line 53-54). Added in Plan 03 (8ba0f9b). GameScene calls it with `true` for self-player (GameScene.ts line ~785).
    implication: Pre-06.7 the self-player path went through `applyServerSnapshot` → predictor replaced local x/y with server's authoritative coordinates, then replayed unacked inputs. Sprite was lerp/snapped to the post-replay predicted state, which started from the SERVER's collision-resolved position. Post-06.7 the self-player skips that path entirely; the local sprite stays at whatever pixel the client's own predictor reached.

  - checked: apps/client/src/scenes/GameScene.ts:currentLayoutForSim() and apps/client/src/prediction/predictor.ts:predictTick()
    found: Client predictor passes `walkable_grid: this.roomRenderer?.walkableGrid` into RoomLayout when grid is available. The room is always derived locally → grid is always present in real BNCentral runs.
    implication: Client's predictTick → step() always takes the walkable_grid branch in step.ts:179-225 (per-axis pixel loop calling isFeetBboxWalkable). Server's step() (no walkable_grid) takes the legacy branch (step.ts:226-237) using resolveCollision (feetBboxIntersectsAABB with STRICT inequality).

  - checked: Compare stop-pixel math for the two paths against a tile-aligned wall.
    Setup: walkable cell N (col), unwalkable cell N+1 to the right. tile_w = 44. NAVI_MASK.right = +9.

    Polys path (feetBboxIntersectsAABB, line 63-74 of collision.ts):
      Wall AABB minX = (N+1) * 44.
      Blocked when feetMaxX > minX, i.e. pos.x + 9 > (N+1) * 44, i.e. pos.x > N*44 + 35.
      Stop at pos.x = N*44 + 35 (last clear pixel).

    Grid path (isFeetBboxWalkable, line 159-182 of collision.ts):
      Probe right corner at px = pos.x + 9. col = Math.floor((pos.x + 9) / 44).
      Blocked when col >= N+1, i.e. pos.x + 9 >= (N+1) * 44, i.e. pos.x >= N*44 + 35.
      Per-axis loop in step.ts:187-202 advances probeX one px at a time and breaks
      on the first BLOCKED probe. So at pos.x = N*44 + 34, the next probe is N*44 + 35
      → blocked → break. Final px = N*44 + 34 (one pixel SHORT of polys stop).

    Difference: grid path stops 1 px farther from wall than polys path on the RIGHT edge.

    found: 1-pixel asymmetry on the +x edge.
    implication: Matches user symptom exactly. The asymmetry comes from `Math.floor`
    in isFeetBboxWalkable treating the right-corner pixel sample as inclusive, while
    feetBboxIntersectsAABB treats the bbox right edge as exclusive (`feetMaxX > b.minX`,
    strict). The two boundary conventions are inconsistent.

  - checked: Same comparison on BOTTOM edge. tile_h = 40, NAVI_MASK.bottom = 0.
    Polys: blocked at pos.y > (N+1)*40 → stop at pos.y = (N+1)*40 = N*40 + 40.
    Grid:  feet-bottom px = pos.y + 0. col blocked when pos.y >= (N+1)*40
           → loop breaks at probeY = (N+1)*40 → stop at pos.y = N*40 + 39.
    found: Grid path stops 1 px farther on bottom edge too.
    implication: Matches user symptom — both right AND bottom report +1 px gap.

  - checked: Same comparison on LEFT and TOP edges (symmetry check).
    Left:  NAVI_MASK.left = -9.
      Polys: blocked at pos.x < K*44 + 9 → stop at pos.x = K*44 + 9.
      Grid:  feet-left px = pos.x - 9. floor((pos.x-9)/44) must remain >= K.
             At pos.x = K*44 + 9 → floor(K*44/44) = K → walkable. At pos.x = K*44 + 8
             → floor((K*44 - 1)/44) = K-1 → blocked. Stop at pos.x = K*44 + 9.
    Top:   NAVI_MASK.top = -9. Mirror of left math — both stop at K*40 + 9.
    found: Left and top edges produce IDENTICAL stop pixels on both paths.
    implication: Explains why operator only reported right+bottom drift. Confirms
    this is not a uniform offset, it is the specific Math.floor-vs-strict-inequality
    asymmetry on the MAX edges.

  - checked: When did the asymmetry first exist? grep history of isFeetBboxWalkable.
    found: Introduced in 0c40432 (Plan 06.1-02, WALK→RUN rename + walkable-grid
    per-axis collision). The 1-px-short behavior has been present in client-side
    prediction since 06.1 but was previously masked by self-player reconciliation
    pulling the sprite to the server's polys-path position.
    implication: This is a latent bug exposed by the client-trust shift, not a new
    bug introduced by Plan 02 or 03's math. The change in OBSERVED behavior is real;
    the underlying arithmetic discrepancy predates 06.7.

  ## Resolution

  root_cause: Boundary-convention mismatch between the client-side walkable-grid collision path and the server-side polys collision path. `packages/game-logic/src/collision.ts:172` uses `Math.floor(px / grid.tile_w)` to sample the right/bottom corner of the feet AABB, treating that corner pixel as INSIDE the bbox. `packages/game-logic/src/collision.ts:69-72` (`feetBboxIntersectsAABB`) treats the bbox using STRICT inequality (`feetMaxX > b.minX`, `feetMaxY > b.minY`), where the corner pixel is OUTSIDE. The two paths produce stops that differ by exactly 1 px on the MAX axes (right, bottom) and agree on the MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path was a corrective sink — `predictor.applyServerSnapshot` replaced the client's predicted position with the server's polys-path-resolved position, then replayed unacked inputs forward — so the user saw the polys-path (flush) stop. Phase 06.7 Plan 03 changed `ReconcileEngine.onServerSnapshot` to early-return for the self-player (`apps/client/src/prediction/reconciler.ts:53-54`), removing the corrective sink. The client now displays the grid-path stop directly, which is 1 px short of pre-06.7 baseline on right and bottom edges only.

  fix: (diagnose-only — not applied)

  verification: (diagnose-only — not applied)

  files_changed: []

  ## Debug Session 3 — v3-rejection-wrong-close-code.md (false positive — context for Plan 07)
  ---
  status: diagnosed
  trigger: "Automated integration test protocol-v4-handshake.integ.test.ts v3-rejection assertion AssertionError: expected 524 to be 4400 (Phase 06.7 UAT Test 5, 2026-05-17)"
  created: 2026-05-17T03:28:00Z
  updated: 2026-05-17T03:35:00Z
  ---

  ## Current Focus

  hypothesis: Reported failure is NOT reproducible at HEAD (commit 9816275). Implementation already throws ServerError(4400) and the SDK surfaces 4400. The UAT capture is stale relative to the current codebase, OR was captured under conditions not reproduced by `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`.
  test: Run the test in isolation (5x) and as part of partial parallel suite — all 8 runs show `v3 client receives close code 4400 with PROTOCOL_VERSION_MISMATCH` PASS.
  expecting: confirm no behavioral change required; UAT entry should be marked stale or reproduction conditions clarified
  next_action: report ROOT CAUSE FOUND — no code change needed; recommend re-running the failed UAT test on the current HEAD to confirm before scheduling any fix work

  ## Symptoms

  expected: `expect(e.code).toBe(4400)` and `expect(String(e.message ?? '')).toMatch(/PROTOCOL_VERSION_MISMATCH/)` both pass for a v3 client; v3 is rejected before Better-Auth.
  actual (per UAT): AssertionError: expected 524 to be 4400 at protocol-v4-handshake.integ.test.ts:43:22 (v3 IS rejected; code is reported as 524 instead of 4400).
  actual (this debug session, HEAD 9816275): test passes — `e.code === 4400`, message matches `/PROTOCOL_VERSION_MISMATCH/`. Verified across 5 isolated runs + 1 partial parallel run (alongside auth/reconnect/boot/sigterm tests).
  errors: AssertionError: expected 524 to be 4400
  reproduction (claimed): `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`
  started: discovered 2026-05-17 during Phase 06.7 UAT Test 5

  ## Eliminated

  - hypothesis: Server `onAuth` throws something other than `ServerError(4400, ...)` for protocol-version mismatch
    evidence: `apps/server/src/RebnoRoom.ts:394-398` explicitly does `throw new ServerError(4400, \`PROTOCOL_VERSION_MISMATCH expected=${v.expected} got=${v.got}\`)` when `validateAuthFrame` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH' }`. `validateAuthFrame` in `packages/protocol/src/intents.ts:350-364` returns that error iff `parsed.data.protocol_version !== PROTOCOL_VERSION` (currently 4).
    timestamp: 2026-05-17T03:30:00Z

  - hypothesis: Colyseus matchmaker intercepts the onAuth-thrown ServerError(4400) and rewrites the code to 524 (MATCHMAKE_EXPIRED)
    evidence: Traced the full server-side error path. `@colyseus/core` Room.cjs:760-789 catches the onAuth exception, deletes the reservation, and re-throws. Only adds a code if missing (`if (!e.code) e.code = APPLICATION_ERROR`) — 4400 is preserved. `@colyseus/ws-transport` WebSocketTransport.cjs:175-177 catches that and calls `client.error(e.code, e.message, ...)` (sends the in-band ERROR frame with code=4400 + msg), then closes the WS with `CloseCode.WITH_ERROR` (4002, not 524). The matchmaker code 524 (`ErrorCode.MATCHMAKE_EXPIRED`) is only thrown from `MatchMaker.cjs` reconnection-token / consumed-seat paths (lines 215-220, 715-720, 748), none of which apply to a fresh joinOrCreate with a stale protocol version.
    timestamp: 2026-05-17T03:31:00Z

  - hypothesis: SDK rewrites code 4400 → 524 during MatchMakeError construction
    evidence: `@colyseus/sdk/dist/colyseus.js:9641-9660` `consumeSeatReservation` registers `room.onError.once((code, message) => reject(new ServerError(code, message)))` and then `createMatchMakeRequest:9716-9722` re-throws as `new MatchMakeError(error.message, error.code)` — code is passed through unchanged. The SDK's WS message handler `colyseus.js:8878-8881` dispatches `Protocol.ERROR` frames as `onError.invoke(code, message)` with the server-sent code intact.
    timestamp: 2026-05-17T03:32:00Z

  - hypothesis: Test is flaky and only sometimes returns 524 instead of 4400
    evidence: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times back-to-back at HEAD 9816275: 3/3 tests passed in every run. Ran a partial parallel suite (protocol-v4 + auth + reconnect + boot + sigterm) — protocol-v4 v3-rejection still PASSED with `e.code === 4400` (252 ms). Concurrent test load did not surface the 524 result.
    timestamp: 2026-05-17T03:34:00Z

  ## Evidence

  - timestamp: 2026-05-17T03:28:00Z
    checked: `git rev-parse HEAD` after `git reset --hard 98162752325bb4bd5cd8d64694b4f175a5fef5fa`
    found: HEAD is `9816275 test(06.7): complete UAT - 3 passed, 2 issues, 1 skipped, 6 gaps logged` — the same commit on which the UAT failure was recorded.
    implication: We're inspecting the exact tree the UAT ran against.

  - timestamp: 2026-05-17T03:29:00Z
    checked: `apps/server/src/RebnoRoom.ts:373-401` (RebnoRoom.onAuth)
    found: Step-1 protocol validation runs FIRST and throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")` (or `c2s.auth PARSE_ERROR` for malformed frames). Step 2 dev-bypass + Step 3 Better-Auth getSession are both gated behind a successful validateAuthFrame.
    implication: Source matches the documented D-03/D-12 contract. No code defect on the server side.

  - timestamp: 2026-05-17T03:29:30Z
    checked: `packages/protocol/src/intents.ts:343-364` (`validateAuthFrame`) + `packages/protocol/src/version.ts:27` (PROTOCOL_VERSION)
    found: `validateAuthFrame({type:'auth', protocol_version: 3, session_token: 'dev-bypass'})` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH', expected: 4, got: 3 }` — matching the test input.
    implication: The validator distinguishes PARSE_ERROR from PROTOCOL_VERSION_MISMATCH correctly; the `if (v.error === 'PROTOCOL_VERSION_MISMATCH')` branch will fire as intended.

  - timestamp: 2026-05-17T03:30:00Z
    checked: Colyseus 0.17 shared-types `ErrorCode` enum at `node_modules/.pnpm/@colyseus+shared-types@0.17.6/.../Protocol.d.ts`
    found: `MATCHMAKE_EXPIRED: 524` is an HTTP matchmaking error code (reconnection-token / seat-consumption races), not a WebSocket close code. WS close codes start at 1000 (`CloseCode.NORMAL_CLOSURE`) with the 4000-range reserved for Colyseus-app (CONSENTED=4000, SERVER_SHUTDOWN=4001, WITH_ERROR=4002, FAILED_TO_RECONNECT=4003, MAY_TRY_RECONNECT=4010).
    implication: A genuine 524 result would mean MatchMaker rejected the seat reservation BEFORE WS upgrade (HTTP-side reconnect or stale-seat path), not that onAuth's 4400 was rewritten.

  - timestamp: 2026-05-17T03:30:30Z
    checked: `node_modules/.pnpm/@colyseus+ws-transport@0.17.../WebSocketTransport.cjs:148-179` (onConnection error handler)
    found: When `connectClientToRoom` throws, transport calls `client.error(e.code, e.message, ...)` to send the in-band ERROR frame (preserving the server-thrown code, here 4400), then closes the WS with `CloseCode.WITH_ERROR` (4002 — not 524). The SDK reads the in-band ERROR frame before the close fires and surfaces it as `ServerError(4400, "PROTOCOL_VERSION_MISMATCH...")`.
    implication: 524 cannot reach the client from the onAuth path — it would require an HTTP-side MatchMaker exception or a reconnection-token race.

  - timestamp: 2026-05-17T03:33:00Z
    checked: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times at HEAD 9816275.
    found: All 5 runs report "Test Files 1 passed (1), Tests 3 passed (3)". Specifically the v3-rejection assertion (line 43 `expect(e.code).toBe(4400)`) passes — `e.code` IS 4400. Server logs show `validateAuthFrame` rejection path firing and the WS close happening before `D-51c onjoin_start` ever logs (auth runs BEFORE onJoin, so no onJoin telemetry for the v3 client — correct).
    implication: The reported AssertionError does not reproduce on the current HEAD. The implementation at HEAD already satisfies the test's expected contract.

  - timestamp: 2026-05-17T03:34:00Z
    checked: Partial parallel suite `protocol-v4-handshake + auth + reconnect + boot + sigterm`.
    found: protocol-v4-handshake v3-rejection still passes (`✓ ... v3 client receives close code 4400 ... 252ms`) under concurrent test load. Other failures in that run (sigterm, boot) are unrelated DB-seed errors — not in scope here.
    implication: The 524 result is not surfaced by adjacent-test concurrency either; the UAT capture is stale or was taken against a different (probably earlier) tree.

  - timestamp: 2026-05-17T03:35:00Z
    checked: `git log --oneline 1ba1fe6..9816275 -- apps/server/test/protocol-v4-handshake.integ.test.ts apps/server/src/RebnoRoom.ts packages/protocol/src/*` — Phase 06.7 commits touching this test or its dependencies.
    found: Only commit `f9b02e3 feat(06.7-02): accept client position updates` touched these files between Plan 01 (`1ba1fe6`) and HEAD (`9816275`). That commit RENAMED `protocol-v3-handshake.integ.test.ts → protocol-v4-handshake.integ.test.ts` and bumped the assertion from v2/v3 to v3/v4 — but did NOT change the assertion that `e.code === 4400`, nor the server-side ServerError(4400) call.
    implication: There is no obvious window in which the implementation diverged from the test. The most likely UAT capture conditions are (a) the test ran against an unbuilt/stale `@rebno/protocol` dist where `PROTOCOL_VERSION` was still 3 — causing the v3 frame to PASS validation, then later fail at the seat-reservation stage with 524; or (b) the test was run from a different worktree that was several commits behind HEAD.

  ## Resolution

  root_cause: Reported failure is not reproducible at HEAD 9816275. The server-side implementation in `apps/server/src/RebnoRoom.ts:394-398` correctly throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")`, the Colyseus WS transport forwards the code 4400 via an in-band ERROR frame, and the SDK surfaces it to the test as `MatchMakeError{ code: 4400, message: "PROTOCOL_VERSION_MISMATCH expected=4 got=3" }`. All 6 test runs (5 isolated + 1 partial-parallel) PASS the `expect(e.code).toBe(4400)` assertion.

  Most likely explanation for the UAT-recorded 524 result: the UAT was executed against a stale `packages/protocol` build artifact (TypeScript dist or cached test transform) where `PROTOCOL_VERSION` was still 3. With version 3 on BOTH server and (test-loaded) `@rebno/protocol`, the v3 frame `validateAuthFrame` passes, the dev-bypass branch in onAuth proceeds, onJoin runs — and a subsequent step in the joinOrCreate → consumeSeatReservation flow rejects with `ErrorCode.MATCHMAKE_EXPIRED` (524). This is consistent with the SUMMARY note that the v4-acceptance case also fails with "seat reservation expired" — the same 524 surface, same Colyseus-matchmaking infra issue that Plan 02 SUMMARY already flagged for `test/reconnect.integ.test.ts`.

  Secondary candidate (lower-probability): the test runner in the UAT was sharing a `node_modules` cache with a worktree at a lower commit where the assertion order was different. The renamed test file (`protocol-v3-handshake.integ.test.ts` → `protocol-v4-handshake.integ.test.ts`) could have caused the runner to load a stale prebuilt copy that asserted against a different code.

  fix: No code change required. Recommended actions for the UAT loop:
    1. From a clean working tree at `9816275`, run `pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` and confirm the 3 tests pass.
    2. If the failure DOES reproduce, capture the actual SDK error object via `console.log({ code: e.code, message: e.message, name: e.name, raw: e })` inside the test's `catch (err)` block on a one-off branch and re-run. The presence of `name: 'MatchMakeError'` + `code: 524` + a message that does NOT contain `PROTOCOL_VERSION_MISMATCH` would confirm the stale-protocol-build hypothesis. The presence of `code: 524` + a message that DOES contain `PROTOCOL_VERSION_MISMATCH` would indicate a new SDK code-rewrite path that did not appear in this investigation.
    3. The UAT entry `06.7-UAT.md:105-114` ("severity: major") should be downgraded to `status: not_reproducible` pending step 1.

  verification: Run `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` → 3 tests pass, including v3-rejection asserting `e.code === 4400`. Verified 6× at HEAD `9816275` in this session. The implementation in `apps/server/src/RebnoRoom.ts:373-401` is correct against the documented D-03/D-12 contract; no source change is required.

  files_changed: []

  ## Plan 05 (two-player remote sync — 3 root causes)
  ---
  phase: 06.7
  plan: 05
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/render/PlayerRenderer.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
    - REQ-SRV-03
  tags: [client, prediction, dispatch, gap-closure, remote-sync]
  must_haves:
    truths:
      - "Every position_update emitted by PositionDispatcher carries an INTEGER monotonic_at_ms — fractional Phaser rAF timestamps never reach the wire, so server zod parse never rejects the frame on the .int() constraint"
      - "Every position_update emitted while the user holds a movement key carries vx ≠ 0 and/or vy ≠ 0 (matching the input-intent axis vector × RUN_SPEED_PX_PER_TICK), NOT the post-step BNO-instant-set vx=0/vy=0"
      - "Every position_update emitted while the user holds NO movement key carries vx = 0 and vy = 0"
      - "Remote-player rendering plays the running animation when the broadcast PlayerState.anim_state.running bit is set, regardless of broadcast vx/vy values (single source of truth on the wire)"
      - "After the three fixes, the 2nd joiner sees the 1st joiner's sprite move, animate, and update at the full sim-tick cadence; the 1st joiner sees the 2nd joiner's sprite update smoothly without dropped frames"
      - "All pre-existing PositionDispatcher unit tests continue to pass (no regression on payload shape, seq monotonicity, setRoom routing, idle cadence, anim_state authoring)"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher with Math.floor(monotonic_at_ms), axis-vector callback, and vx/vy authored from input intent (not post-step PlayerSim)"
        contains: "Math.floor(monotonic_at_ms)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Regression tests for RC #1 (fractional monotonic_at_ms floor) and RC #2 (vx/vy authored from axis vector callback)"
        contains: "1234.567"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "Wires axis-vector callback into PositionDispatcher constructor; remote sim-tick loop passes unpackAnimState(p.anim_state).running into onSimulationTickRemote"
        contains: "unpackAnimState"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        provides: "onSimulationTickRemote consumes an explicit isRunning flag (from broadcast anim_state) instead of deriving it from vx/vy hypot"
        contains: "isRunning"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts:1193 (sendTick call site)"
        to: "apps/client/src/prediction/position-dispatcher.ts:sendTick monotonic_at_ms payload field"
        via: "Math.floor coercion at the dispatcher boundary"
        pattern: "Math\\.floor\\(monotonic_at_ms\\)"
      - from: "apps/client/src/scenes/GameScene.ts:676-686 (dispatcher construction)"
        to: "apps/client/src/prediction/position-dispatcher.ts sendTick vx/vy fields"
        via: "new axis-vector callback (parallel to existing getFacing / getIsRunning)"
        pattern: "getAxisVector"
      - from: "apps/client/src/scenes/GameScene.ts:1213-1226 (remote sim-tick loop)"
        to: "apps/client/src/render/PlayerRenderer.ts:onSimulationTickRemote"
        via: "new isRunning argument decoded from unpackAnimState(p.anim_state).running"
        pattern: "unpackAnimState\\(p\\.anim_state\\)"
  ---

  <objective>
  Restore the REQ-CLI-08 two-player smoke milestone: each peer sees the other's
  position, velocity, facing, and running animation update in near-real-time.

  Closes three layered root causes diagnosed in
  `.planning/debug/two-player-remote-sync-broken.md`:
    RC #1 — fractional monotonic_at_ms fails the wire schema's .int() constraint,
            dropping ~100% of position_update frames at the server boundary.
    RC #2 — PositionDispatcher reads vx/vy from PredictionEngine.getLocalState(),
            which always returns vx=0/vy=0 per the BNO instant-set model.
            Every wire frame carries vx=0, vy=0; server echoes 0; remote
            renderer derives Stand-frame from hypot(0,0)=0.
    RC #3 — Remote-render loop reads vx/vy only and ignores broadcast
            anim_state. Even when RC #2 is fixed, this is the principled
            single-source-of-truth path on the wire.

  Purpose: REQ-CLI-08 HARD MILESTONE regression. Pre-06.7 worked. Phase 06.7
  client-trust flip introduced these three latent defects.

  Output: Three atomic commits restoring two-player sync. Operator UAT Test 3
  (two-player smoke), Test 4 (was-Test-3 incidental edge collision passes
  separately via Plan 06), and Test 6 (idle cadence) all PASS post-fix.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/two-player-remote-sync-broken.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md
  @apps/client/src/prediction/position-dispatcher.ts
  @apps/client/src/__test__/position-dispatcher.test.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/render/PlayerRenderer.ts
  @packages/protocol/src/intents.ts
  @packages/protocol/src/anim-state.ts
  @packages/game-logic/src/step.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/protocol/src/intents.ts (cPositionUpdateSchema, lines 125-137):
  ```
  monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)
  vx, vy:          z.number().int().min(-32768).max(32767)
  anim_state:      z.number().int().min(0).max(255)
  ```

  From packages/protocol/src/anim-state.ts:
  ```
  export function unpackAnimState(byte: number): { facing: Direction; running: boolean }
  export function packAnimState(facing: Direction, isRunning: boolean): number
  export type Direction = 'D'|'DR'|'R'|'UR'|'U'|'UL'|'L'|'DL'
  ```

  From packages/game-logic/src/constants.ts:
  ```
  export const RUN_SPEED_PX_PER_TICK: number = 5;
  ```

  From apps/client/src/prediction/position-dispatcher.ts (current constructor):
  ```
  constructor(
    room: Pick<Room, 'send'>,
    prediction: Pick<PredictionEngine, 'getLocalState'>,
    getFacing: () => Direction,
    getIsRunning: () => boolean,
  )
  ```

  From apps/client/src/scenes/GameScene.ts:64:
  ```
  import { RUN_SPEED_PX_PER_TICK, type Direction } from '@rebno/game-logic';
  ```

  From apps/client/src/scenes/GameScene.ts:676-686 (current dispatcher construction
  — note the inline axis-vector computation that already exists for getIsRunning;
  the new axis-vector callback should use the same source):
  ```
  this.positionDispatcher = new PositionDispatcher(
    this.room,
    this.prediction,
    () => this.playerRenderer?.getLocalFacing() ?? 'D',
    () => {
      const vx = (this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK;
      const vy = (this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK;
      return Math.hypot(vx, vy) > 0;
    },
  );
  ```

  From apps/client/src/scenes/GameScene.ts:1218-1224 (current remote sim-tick loop):
  ```
  this.playerRenderer.onSimulationTickRemote(
    sessionId,
    vx,
    vy,
    p.x ?? 0,
    p.y ?? 0,
  );
  ```
  (PlayerSnapshotShape.anim_state is already declared optional at line 122.)

  From apps/client/src/render/PlayerRenderer.ts:423-470 (current
  onSimulationTickRemote derives isRunning via hypot(renderVx, renderVy) inside
  deriveFrameWithAuthoritativeFacing).
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1 (RC #1): Floor monotonic_at_ms at the dispatcher boundary + regression test</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts</files>
    <behavior>
      - RED: Add a regression test `it('RC #1 — floors fractional monotonic_at_ms to satisfy wire .int() contract', ...)` that calls `dispatcher.sendTick(1234.567)` and asserts the captured payload's `monotonic_at_ms === 1234` AND `cPositionUpdateSchema.safeParse(payload).success === true`. Run vitest — MUST fail because the current dispatcher passes 1234.567 through unmodified and zod rejects it.
      - GREEN: Change `apps/client/src/prediction/position-dispatcher.ts` line 42 from `monotonic_at_ms,` to `monotonic_at_ms: Math.floor(monotonic_at_ms),`. Update the surrounding comment to cite RC #1 from `.planning/debug/two-player-remote-sync-broken.md` and explain that Phaser's `_time` is a fractional rAF/performance.now timestamp while the wire schema requires `.int()`. Carry the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` traceable-reqs tags.
      - Re-run vitest — MUST pass.
      - Verify the existing tests (`sends a valid position_update payload with rounded local state`, `increments seq monotonically`, `setRoom routes subsequent ticks`, `uses the injected facing and running callbacks`, `D-06 idle cadence`) all still pass with their integer time args.
    </behavior>
    <action>
      Apply RC #1 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #1 option (a) — Math.floor coercion at the dispatcher boundary. Do NOT loosen the schema in `packages/protocol/src/intents.ts` (option (b) was rejected — preserves the u32 binary contract per Plan 01 codex review concern #7 fold).

      Test placement: append new `it(...)` inside the existing `describe('PositionDispatcher', ...)` block in `apps/client/src/__test__/position-dispatcher.test.ts`. Use the existing `makeMockRoom` / `makeMockPrediction` helpers and `cPositionUpdateSchema` import (already present at top of file). Tag the test with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]` to match the file's convention.

      Commit message: `fix(06.7): floor monotonic_at_ms at dispatcher boundary (RC #1)`. Body must reference `.planning/debug/two-player-remote-sync-broken.md` and explain that fractional Phaser timestamps were failing the wire schema's `.int()` constraint.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass including the new fractional-timestamp regression test. Dispatcher source contains `Math.floor(monotonic_at_ms)` at line 42 (or equivalent) with the RC #1 comment and traceable-reqs tags intact.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (RC #2): Author vx/vy from input-intent axis vector via injected callback</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Add two regression tests inside `describe('PositionDispatcher', ...)`:
          (a) `it('RC #2 — authors vx/vy from injected axis-vector callback during movement', ...)` — construct the dispatcher with a `getAxisVector` callback that returns `{ axisX: 1, axisY: -1 }`. Mock prediction returns `{ x: 100, y: 200, vx: 0, vy: 0 }` (post-step BNO instant-set). Call `sendTick(1000)`. Assert the captured payload's `vx === Math.round(1 * RUN_SPEED_PX_PER_TICK)` (= 5) and `vy === Math.round(-1 * RUN_SPEED_PX_PER_TICK)` (= -5). Assert `cPositionUpdateSchema.safeParse(payload).success === true`.
          (b) `it('RC #2 — authors vx/vy as 0 when no axis is held', ...)` — callback returns `{ axisX: 0, axisY: 0 }`. Assert payload `vx === 0` and `vy === 0`.
        Both tests MUST fail initially because the current dispatcher reads `local.vx / local.vy` (always 0).
      - GREEN: Modify `apps/client/src/prediction/position-dispatcher.ts`:
          * Add a 5th constructor parameter `getAxisVector: () => { axisX: -1 | 0 | 1; axisY: -1 | 0 | 1 }` (parallel to existing `getFacing` / `getIsRunning`).
          * Import `RUN_SPEED_PX_PER_TICK` from `@rebno/game-logic` at the top of the file.
          * Replace lines 37-38 (`vx: Math.round(local.vx), vy: Math.round(local.vy),`) with:
              `const axes = this.getAxisVector();`
              `vx: Math.round(axes.axisX * RUN_SPEED_PX_PER_TICK),`
              `vy: Math.round(axes.axisY * RUN_SPEED_PX_PER_TICK),`
          * Drop the unused `prediction` parameter ONLY IF no other call site reads `local.x` / `local.y` — currently lines 35-36 still consume `local.x` and `local.y`, so keep `prediction` and only swap the velocity reads.
          * Update the header comment to cite RC #2 and explain that vx/vy on the wire MUST represent current input INTENT (per-tick displacement vector × RUN_SPEED_PX_PER_TICK), NOT the post-step PlayerSim's BNO-instant-set vx=0. Mirror the GameScene.ts:1199-1200 pattern that already does this for `onSimulationTickLocal`.
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:676-686`:
          * Add a 5th argument to the `new PositionDispatcher(...)` call: `() => ({ axisX: (this.inputDispatcher?.axisX() ?? 0) as -1|0|1, axisY: (this.inputDispatcher?.axisY() ?? 0) as -1|0|1 })`.
          * The existing 4th callback `getIsRunning` can be left as-is OR simplified to consume the same axis source — leave as-is to minimize blast radius.
      - Update existing PositionDispatcher tests that construct the dispatcher with the old 4-arg signature: add a 5th `() => ({ axisX: 0, axisY: 0 })` arg (idle axis vector — preserves existing assertions about vx/vy from `state = { vx: 1.1, vy: -2.4 }` becoming vx/vy=0 on the wire after RC #2 — UPDATE those assertions accordingly: the old test `sends a valid position_update payload with rounded local state` asserted `vx: 1, vy: -2`; after RC #2 those become `vx: 0, vy: 0` because the wire vx/vy now ignores prediction state and reads from the axis callback). Re-state the test's intent in its description to reflect the new contract.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #2 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #2. Inject an axis-vector callback into PositionDispatcher parallel to `getFacing` / `getIsRunning`. Compute vx/vy from the axis vector × RUN_SPEED_PX_PER_TICK (mirroring GameScene.ts:1199-1200 used for `onSimulationTickLocal`). Do NOT modify `packages/game-logic/src/step.ts` — the BNO instant-set vx=0/vy=0 contract is correct for the simulation; this fix moves the wire-side velocity authoring to the dispatcher.

      Constructor signature change is a callsite update — only one production callsite (GameScene.ts:676-686) and four test constructions (apps/client/src/__test__/position-dispatcher.test.ts). All five must be updated.

      Update existing test assertions that pinned vx=1/vy=-2 on the wire — those assertions are now obsolete (wire vx/vy is sourced from axes, not prediction). New contract: wire vx/vy = round(axisX × 5), round(axisY × 5).

      Tag the new tests `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag the modified dispatcher code with the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` markers.

      Commit message: `fix(06.7): author wire vx/vy from input-intent axis vector (RC #2)`. Body explains BNO instant-set returns vx=0/vy=0 from step() so the dispatcher must derive wire velocity from the input axes, mirroring the local-renderer pattern at GameScene.ts:1199-1200.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass (existing + 2 new RC #2 tests + 1 new RC #1 test from Task 1). Dispatcher constructor takes 5 args including the new axis-vector callback. Wire vx/vy carries `round(axis × RUN_SPEED_PX_PER_TICK)`. GameScene constructs the dispatcher with the new callback wired to `inputDispatcher.axisX()` / `axisY()`. `pnpm -C apps/client exec tsc --noEmit` is green.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3 (RC #3): Remote-render consumes broadcast anim_state.running instead of deriving from vx/vy</name>
    <files>apps/client/src/render/PlayerRenderer.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Locate or create a PlayerRenderer.onSimulationTickRemote unit test in `apps/client/src/__test__/`. If `player-renderer.test.ts` (or similar) exists, add a test `it('RC #3 — consumes explicit isRunning flag instead of deriving from vx/vy hypot', ...)` that:
          * Sets up a remote with a known frame.
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, /* isRunning */ true)` and asserts the rendered frame key matches a Run-* sprite (e.g. starts with a Run prefix per `deriveFrameWithAuthoritativeFacing` contract).
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, false)` and asserts a Stand-* frame.
        If no test file exists, create `apps/client/src/__test__/player-renderer-remote-anim.test.ts` with minimal Phaser scene mocking sufficient to exercise the frame-key derivation path. Use the existing `firstRemoteFrame` window hook (`__rebno.firstRemoteFrame` in PlayerRenderer.ts:485) as the observable signal.
        Test MUST fail before the GREEN change because the renderer currently ignores any explicit isRunning arg.
      - GREEN: Modify `apps/client/src/render/PlayerRenderer.ts:423-470 onSimulationTickRemote`:
          * Add a new optional 6th parameter `isRunning?: boolean`.
          * When `isRunning` is provided, override the running-flag derivation: pass an explicit running override into `deriveFrameWithAuthoritativeFacing`. Two implementation options:
              (a) Add an `isRunningOverride?: boolean` arg to `deriveFrameWithAuthoritativeFacing` and short-circuit its internal hypot check when defined. Cleaner.
              (b) Pre-compute the running flag locally and call a variant. Smaller blast radius if (a) requires touching multiple deriveFrame call paths.
            Prefer (a). If `deriveFrameWithAuthoritativeFacing` lives in `apps/client/src/render/SpriteStateMachine.ts` and has unit tests, add an optional `isRunningOverride?: boolean` parameter that defaults to undefined (preserves existing behavior for callers that don't pass it).
          * Document the change with a comment citing RC #3 from `.planning/debug/two-player-remote-sync-broken.md` and Hard Rule 1 carve-out (movement = client-authoritative; anim_state byte is the single source of truth on the wire).
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:1213-1226 remote sim-tick loop`:
          * Import `unpackAnimState` from `@rebno/protocol` (add to existing import line).
          * Inside the for loop, after reading `const vx = p.vx ?? 0; const vy = p.vy ?? 0;`, add:
              `const animByte = p.anim_state ?? 0;`
              `const isRunning = unpackAnimState(animByte).running;`
          * Pass `isRunning` as a 6th argument to `playerRenderer.onSimulationTickRemote(sessionId, vx, vy, p.x ?? 0, p.y ?? 0, isRunning)`.
          * Update the surrounding comment to cite RC #3 — broadcast anim_state.running is the canonical signal; vx/vy are kept as positional/animation inputs but the running flag now flows from the explicit byte.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #3 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #3 option (a) — the principled single-source-of-truth path. Even though RC #2 alone (Task 2) would propagate non-zero vx/vy and incidentally fix the animation via the hypot derivation, RC #3 makes the anim_state byte the canonical wire signal and decouples animation correctness from velocity correctness. This survives future changes where vx/vy might be quantized, clamped, or zeroed for anti-cheat.

      If `deriveFrameWithAuthoritativeFacing` is exported from `apps/client/src/render/SpriteStateMachine.ts`, add the optional `isRunningOverride?: boolean` arg there and update existing call sites in PlayerRenderer.ts to pass `undefined` (preserves behavior). The new remote path passes the unpacked anim_state.running flag.

      PlayerSnapshotShape at GameScene.ts:122 already declares `anim_state?: number` — no shape change needed.

      Tag any new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag modified renderer code with `[impl->REQ-CLI-04] [impl->REQ-CLI-08]`.

      Commit message: `fix(06.7): remote-render consumes broadcast anim_state.running (RC #3)`. Body explains that the broadcast anim_state byte is the canonical wire signal per Hard Rule 1's movement carve-out; previously the remote renderer ignored it and re-derived from vx/vy hypot, masking RC #2.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run</automated>
    </verify>
    <done>
      All apps/client vitest tests pass (including any new RC #3 player-renderer test). `pnpm -C apps/client exec tsc --noEmit` is green. GameScene.ts remote-sim-tick loop calls `playerRenderer.onSimulationTickRemote(sid, vx, vy, x, y, isRunning)` with `isRunning` decoded via `unpackAnimState(p.anim_state ?? 0).running`. PlayerRenderer.onSimulationTickRemote honors the explicit isRunning override when provided.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (position_update wire path) | client-authored payload; server zod-validates; carve-out: server stores and broadcasts without semantic validation per Hard Rule 1's narrow movement carve-out (anti-cheat deferred to Phase 06.8) |
  | server → client (state-sync of broadcast PlayerState) | server-authoritative broadcast of last-known position/velocity/facing/anim_state; client renderer trusts these values for remote players |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-05-01 | Tampering | wire monotonic_at_ms (after Math.floor coercion) | accept | The .int() schema constraint remains enforced server-side. Math.floor at the client dispatcher is a correctness fix for honest clients; a malicious client could still craft any integer value. Bound by .min(0).max(0xffffffff) already in the schema. |
  | T-06.7-05-02 | Tampering | wire vx/vy (now sourced from injected axis-vector callback) | accept | Wire vx/vy are still client-authored — narrow movement carve-out (Hard Rule 1) accepts this. Server stores and broadcasts. Anti-cheat (illegal-position fall trigger, speed cap, walkable-grid sanity) is deferred to Phase 06.8 per Plan 04 deferral note. |
  | T-06.7-05-03 | Tampering | broadcast anim_state.running consumed by remote renderer | accept | A malicious peer could broadcast running=true while sending vx=0/vy=0 (visual desync only — no gameplay-state effect). The anim_state byte is authored by the broadcasting client and trusted by other clients for rendering. Deferred to 06.8 anti-cheat. |
  | T-06.7-05-04 | Information Disclosure | injected axis-vector callback exposing inputDispatcher state | accept | The callback returns axisX/axisY (-1/0/1) which is already implicit in the existing wire payload (deducible from vx/vy in the OLD-Phase-pre-06.7 derivation pattern at GameScene.ts:1199-1200). No new disclosure surface. |
  </threat_model>

  <verification>
    - All three RCs fixed in three atomic commits.
    - `pnpm -C apps/client exec vitest run` — green (existing 247 tests + 4 new regression tests).
    - `pnpm -C apps/client exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (no schema change but executor MUST run this to verify nothing in @rebno/protocol export surface broke; lesson from Test 5 stale-protocol-build false positive — note recorded by Plan 07).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: re-run Test 4 (two-player smoke) — 2nd joiner sees 1st joiner's position updates, velocity-driven movement, and running animation. 1st joiner sees same of 2nd. Both should PASS.
    - Operator HUMAN-UAT Test 6 (idle cadence ~150 frames over 5s) re-checkable post-fix since RC #1 was blocking that observation.
  </verification>

  <success_criteria>
  - Three commits land on the main branch (or 06.7 branch per execute-phase worktree convention), each with a clear `fix(06.7): ... (RC #N)` message.
  - The 4 new regression tests (1 for RC #1, 2 for RC #2, 1 for RC #3) all pass.
  - All pre-existing PositionDispatcher and PlayerRenderer tests still pass (with updated assertions where the RC #2 contract change made old assertions obsolete).
  - Operator re-runs UAT Test 4 two-player smoke and reports `pass`.
  - UAT.md gap entries for the three blocker truths flip from `status: failed` to `status: resolved` (or new UAT pass log entry); STATE.md and ROADMAP.md update is the responsibility of `/gsd-verify-work 06.7` after the operator signs off.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-05-SUMMARY.md` documenting:
  - The three RCs and which commit fixed each.
  - The new constructor signature for PositionDispatcher (5 args).
  - The new optional 6th arg `isRunning` for `onSimulationTickRemote` and the underlying `isRunningOverride` on `deriveFrameWithAuthoritativeFacing`.
  - All regression tests added (file + describe name + it name).
  - The four pre-existing test assertions that needed updating to reflect the RC #2 contract change.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/two-player-remote-sync-broken.md` and the three UAT gap entries.
  </output>

  ## Plan 06 (edge collision off-by-1px)
  ---
  phase: 06.7
  plan: 06
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/game-logic/src/collision.ts
    - packages/game-logic/test/collision.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  tags: [game-logic, collision, gap-closure, edge-off-by-one]
  must_haves:
    truths:
      - "After the fix, the walkable-grid path (isFeetBboxWalkable, consumed by client-side prediction in step.ts:179-225) stops the player AT THE SAME PIXEL as the legacy polys path (feetBboxIntersectsAABB, consumed by server-side step.ts:226-237) for every wall orientation: right, bottom, left, top"
      - "Right-edge tile-boundary wall stop matches pre-06.7 baseline: pos.x = N*44 + 35 (where N is the last walkable column), NOT N*44 + 34"
      - "Bottom-edge tile-boundary wall stop matches pre-06.7 baseline: pos.y = N*40 + 40 (where N is the last walkable row), NOT N*40 + 39"
      - "Left-edge and top-edge stops remain unchanged at K*44 + 9 and K*40 + 9 respectively (no regression on the agreeing axes)"
      - "Pre-existing collision.test.ts and step.test.ts assertions continue to pass (no regression on left/top stops, axis-slide composition, or full-stop-when-fully-blocked behavior)"
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        provides: "isFeetBboxWalkable with right/bottom corner samples normalized to EXCLUSIVE bbox boundary (matches feetBboxIntersectsAABB strict-inequality convention)"
        contains: "right - 1"
      - path: "packages/game-logic/test/collision.test.ts"
        provides: "Regression tests pinning right-edge AND bottom-edge stop pixels against tile-boundary walls in walkable-grid path; left+top symmetry tests unchanged"
        contains: "edge-off-by-one"
      - path: "packages/game-logic/test/step.test.ts"
        provides: "End-to-end step() regression: a (5,0) tick into a right-edge tile-boundary wall reaches the same final pixel under grid path as under polys path"
        contains: "parity"
    key_links:
      - from: "packages/game-logic/src/step.ts:194 (probeX + NAVI_MASK.right)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable right-corner sample"
        via: "exclusive-boundary right-corner adjustment inside isFeetBboxWalkable"
        pattern: "right - 1"
      - from: "packages/game-logic/src/step.ts:215 (probeY + NAVI_MASK.bottom)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable bottom-corner sample"
        via: "exclusive-boundary bottom-corner adjustment inside isFeetBboxWalkable"
        pattern: "bottom - 1"
  ---

  <objective>
  Close the 1-pixel edge-collision asymmetry exposed by Phase 06.7's self-player
  reconciler no-op (UAT.md gap 4, severity minor).

  Root cause (`.planning/debug/edge-collision-off-by-one.md`):
  Boundary-convention mismatch between two collision paths in
  `packages/game-logic/src/collision.ts`:
    - Client-side walkable-grid path (`isFeetBboxWalkable`, line 172) treats the
      right/bottom corner pixel as INCLUSIVE via `Math.floor(px / tile_w)`.
    - Server-side polys path (`feetBboxIntersectsAABB`, lines 69-72) treats the
      bbox boundary as EXCLUSIVE via strict `feetMaxX > b.minX`.
  The two paths disagree by exactly 1 px on MAX axes (right, bottom) and agree
  on MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path masked
  the discrepancy by snapping the local sprite to the server's polys-path stop;
  Phase 06.7 Plan 03's `isSelfPlayer` early-return in `reconciler.ts:53-54`
  removed that corrective sink, exposing the latent grid-path 1-px-short stop.

  Purpose: restore pixel-perfect parity between client prediction and server
  authority on edge collisions. Pre-existing latent bug from Plan 06.1-02
  (`0c40432`), exposed by Phase 06.7 client-trust flip.

  Output: One atomic commit normalizing the right/bottom corner samples in
  `isFeetBboxWalkable` to be EXCLUSIVE of the bbox boundary, with regression
  tests pinning the stop pixels on all four edges. Operator UAT Test 4 edge-
  collision gap re-checks PASS.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/edge-collision-off-by-one.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @packages/game-logic/src/collision.ts
  @packages/game-logic/src/step.ts
  @packages/game-logic/src/constants.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/game-logic/src/constants.ts:
  ```
  export const NAVI_MASK = { left: -9, right: 9, top: -9, bottom: 0 } as const;
  // tile_w = 44, tile_h = 40 (from CLAUDE.md extracted constants)
  ```

  From packages/game-logic/src/collision.ts:63-74 (feetBboxIntersectsAABB —
  the EXCLUSIVE-boundary reference convention to match):
  ```
  function feetBboxIntersectsAABB(pos: Vec2, b: AABB): boolean {
    const feetMinX = pos.x + NAVI_MASK.left;
    const feetMaxX = pos.x + NAVI_MASK.right;
    const feetMinY = pos.y + NAVI_MASK.top;
    const feetMaxY = pos.y + NAVI_MASK.bottom;
    return (
      feetMaxX > b.minX &&   // STRICT — corner at b.minX is OUTSIDE
      feetMinX < b.maxX &&
      feetMaxY > b.minY &&   // STRICT — corner at b.minY is OUTSIDE
      feetMinY < b.maxY
    );
  }
  ```

  From packages/game-logic/src/collision.ts:159-182 (current
  isFeetBboxWalkable — the path to FIX):
  ```
  export function isFeetBboxWalkable(
    grid: WalkableGrid,
    left: number,
    top: number,
    right: number,
    bottom: number,
  ): boolean {
    const xs: ReadonlyArray<number> = [left, right];
    const ys: ReadonlyArray<number> = [top, bottom];
    for (let xi = 0; xi < xs.length; xi++) {
      const px = xs[xi]!;
      const col = Math.floor(px / grid.tile_w);
      if (col < 0 || col >= grid.width_tiles) return false;
      for (let yi = 0; yi < ys.length; yi++) {
        const py = ys[yi]!;
        const row = Math.floor(py / grid.tile_h);
        if (row < 0 || row >= grid.height_tiles) return false;
        if (grid.cells[row * grid.width_tiles + col] !== 1) return false;
      }
    }
    return true;
  }
  ```

  From packages/game-logic/src/step.ts:187-202 (X-axis probe call site —
  caller passes feet-bbox right corner as `probeX + NAVI_MASK.right`):
  ```
  for (let i = 0; i < stepsX; i++) {
    const probeX = px + signX;
    if (
      isFeetBboxWalkable(
        grid,
        probeX + NAVI_MASK.left,
        py + NAVI_MASK.top,
        probeX + NAVI_MASK.right,    // right-corner pixel of feet bbox
        py + NAVI_MASK.bottom,        // bottom-corner pixel of feet bbox
      )
    ) { px = probeX; } else { break; }
  }
  ```

  Stop-pixel math (from `.planning/debug/edge-collision-off-by-one.md`):
    Wall at tile boundary: col N walkable, col N+1 unwalkable. tile_w = 44.
    - Polys path stop: pos.x = N*44 + 35 (last clear pixel; feetMaxX = 44N+44 = b.minX → not strictly greater).
    - Grid path stop (CURRENT BUG): pos.x = N*44 + 34 (one short; probe at +35 has col = floor((44N+44)/44) = N+1 → blocked).
    - Grid path stop (AFTER FIX, right-1 adjustment): pos.x = N*44 + 35 (probe at +35 has col = floor((44N+44-1)/44) = N → walkable; probe at +36 has col = floor((44N+45-1)/44) = floor((44N+44)/44) = N+1 → blocked).
    Bottom-edge math symmetric with tile_h = 40, NAVI_MASK.bottom = 0.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Normalize right/bottom corner samples in isFeetBboxWalkable to exclusive boundary + regression tests</name>
    <files>packages/game-logic/src/collision.ts, packages/game-logic/test/collision.test.ts, packages/game-logic/test/step.test.ts</files>
    <behavior>
      - RED (collision.test.ts):
          Add a `describe('isFeetBboxWalkable edge-off-by-one (Plan 06.7-06)', ...)` block with four `it(...)` tests:
          (a) `it('right-edge tile-boundary wall: bbox flush against wall (right corner at b.minX) is walkable', ...)` — construct a grid where col N is walkable, col N+1 is unwalkable. Call `isFeetBboxWalkable(grid, left=N*44-9, top=0, right=N*44+44, bottom=0)` (i.e., pos.x = N*44 + 35 → right corner = N*44 + 44 = boundary). Assert `true` (flush is OK under EXCLUSIVE convention).
          (b) `it('right-edge tile-boundary wall: bbox one px past wall (right corner at b.minX + 1) is blocked', ...)` — same grid. Call `isFeetBboxWalkable(grid, left=N*44-8, top=0, right=N*44+45, bottom=0)`. Assert `false`.
          (c) `it('bottom-edge tile-boundary wall: bbox flush (bottom corner at b.minY) is walkable', ...)` — col 0 walkable rows 0..M-1, row M unwalkable. Call `isFeetBboxWalkable(grid, left=0, top=M*40-9, right=18, bottom=M*40)`. Assert `true`.
          (d) `it('bottom-edge tile-boundary wall: bbox one px past (bottom corner at b.minY + 1) is blocked', ...)` — Assert `false`.
          Test (a) and (c) MUST fail initially because current `Math.floor(px / tile_w)` treats the boundary pixel as inside cell N+1 (or row M).
      - RED (step.test.ts):
          Add a `describe('step() edge-collision parity (Plan 06.7-06)', ...)` block with two `it(...)` tests asserting end-to-end stop-pixel parity between grid path and polys path:
          (e) `it('right-edge: grid-path final px matches polys-path final px after a (+5, 0) tick into tile-boundary wall', ...)` — build two PlayerSims at pos = (N*44 + 30, y0), tick +5 px right; one with `walkable_grid` set (grid path), one with `collision_polys` set (polys path). Assert both `final.players.get(sid).x === N*44 + 35`.
          (f) `it('bottom-edge: grid-path final py matches polys-path final py after a (0, +5) tick into tile-boundary wall', ...)` — analogous on Y axis. Assert both `final.players.get(sid).y === M*40 + 40`. NOTE: NAVI_MASK.bottom = 0, so the math is `pos.y = M*40` (not M*40+40). Confirm against the debug doc: pos.y = M*40 + 40 — the +40 comes from feetMaxY = pos.y + 0 = pos.y; blocked when pos.y > M*40; so last clear pos.y = M*40 exactly. UPDATE THE ASSERTION to `M*40` (debug doc's "N*40 + 40" where N = M-1, last walkable row, is the same pixel as M*40 where M = first unwalkable row). Document the indexing convention in a test comment to avoid future confusion.
          Both tests MUST fail initially: grid path returns one px short on right (and on bottom).
      - GREEN: Modify `packages/game-logic/src/collision.ts` `isFeetBboxWalkable` to make the right/bottom corner samples EXCLUSIVE of the bbox boundary. Implementation:
          Inside the function, BEFORE the `xs`/`ys` array construction, derive adjusted samples:
          ```
          // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
          // Right/bottom corner samples are EXCLUSIVE of the feet-bbox boundary,
          // matching feetBboxIntersectsAABB's strict-inequality convention
          // (collision.ts:69-72). Without this adjustment, the grid path stops the
          // player 1 px short on right+bottom edges vs the polys path — exposed
          // by Phase 06.7 self-player reconciler no-op. See
          // .planning/debug/edge-collision-off-by-one.md.
          const rightExclusive = right - 1;
          const bottomExclusive = bottom - 1;
          const xs: ReadonlyArray<number> = [left, rightExclusive];
          const ys: ReadonlyArray<number> = [top, bottomExclusive];
          ```
          Do NOT change the parameter signature — callers still pass `right` and `bottom` as the bbox boundary; the helper internally adjusts.
          Preserve existing traceable-reqs tags on the function.
      - Re-run vitest — all tests MUST pass.
      - Verify pre-existing tests (left/top symmetry, axis-slide, fully-blocked break) still pass.
    </behavior>
    <action>
      Apply fix from `.planning/debug/edge-collision-off-by-one.md` Resolution §root_cause via the in-helper adjustment option (UAT.md gap 4 missing-item #2 "adjust inside isFeetBboxWalkable for right/bottom only"). Chosen over the call-site adjustment in step.ts because:
        - Single chokepoint — no risk of new callers forgetting the subtract-1
        - Helper contract stays "pass the bbox boundary"; convention is encapsulated
        - Matches Plan 06.1-02 author intent (helper owns walkability semantics, not callers)

      Test placement:
        - collision.test.ts: append the new `describe(...)` block alongside existing isFeetBboxWalkable tests. Use existing `makeGrid` helper if one exists; otherwise inline a minimal `{ tile_w: 44, tile_h: 40, width_tiles, height_tiles, cells }` literal.
        - step.test.ts: append the new `describe(...)` block alongside existing step() tests. Reuse existing player/state builders.

      Tag all new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`.

      Commit message: `fix(06.7): normalize edge-collision grid path to match polys boundary convention`. Body must reference `.planning/debug/edge-collision-off-by-one.md` and explain the 1-px right/bottom asymmetry, the exclusive-boundary convention, and the self-player reconciler no-op as the trigger that exposed the latent bug.
    </action>
    <verify>
      <automated>pnpm -C packages/game-logic exec vitest run</automated>
    </verify>
    <done>
      All game-logic vitest tests pass (existing + 6 new edge-parity tests). `pnpm -C packages/game-logic exec tsc --noEmit` is green. `pnpm trace:check` is green. Right/bottom edge tile-boundary walls produce identical stop pixels under both grid path and polys path.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client prediction → server authority | Both run the SAME `step()` from `@rebno/game-logic`; after fix, both branches agree pixel-for-pixel on edge stops. Removes the 1-px self-prediction/server-authority drift that the self-player reconciler used to mask. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-06-01 | Tampering | client-side walkable-grid (`this.roomRenderer?.walkableGrid` passed into step) | accept | Already accepted in Phase 06.1-02. Client could fabricate a walkable grid permitting illegal positions; server doesn't consume the grid (server step takes polys branch, step.ts:226-237). Anti-cheat deferred to Phase 06.8 per Hard Rule 1 carve-out. This plan does not change the trust posture; it only removes a 1-px asymmetry that would otherwise produce server→client reconcile churn once anti-cheat lands. |
  | T-06.7-06-02 | Denial of Service | regression in axis-slide composition (Y advances from post-X position) | accept | Mitigated by pre-existing axis-slide tests in collision.test.ts and step.test.ts. New tests do NOT modify the loop structure, only the corner-sample math inside `isFeetBboxWalkable`. Composition contract preserved. |
  </threat_model>

  <verification>
    - `pnpm -C packages/game-logic exec vitest run` — green (existing + 6 new edge-parity tests).
    - `pnpm -C packages/game-logic exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (sanity, no schema change — Test 5 stale-build lesson).
    - `pnpm -C apps/client exec vitest run` — green (consumer of @rebno/game-logic; no regression on prediction tests).
    - `pnpm -C apps/server exec vitest run` — green (server step() takes polys branch — fix is logically idempotent for server, but verify nothing broke).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: walk right and down into BNCentral floor edges; sprite stops flush against the wall (no visible 1-px gap). Compare against pre-06.7 baseline screenshot if available.
  </verification>

  <success_criteria>
  - One commit lands with message `fix(06.7): normalize edge-collision grid path to match polys boundary convention`.
  - The 6 new regression tests (4 in collision.test.ts, 2 in step.test.ts) all pass.
  - All pre-existing game-logic, apps/client, and apps/server tests still pass.
  - Operator re-checks UAT Test 4 edge-collision sub-gap and reports `pass` (stop pixels flush against right/bottom walls).
  - UAT.md gap entry for "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline" flips from `status: failed` to `status: resolved`.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-06-SUMMARY.md` documenting:
  - The 1-px boundary-convention mismatch and which line fixed it (`isFeetBboxWalkable` right/bottom corner adjustment).
  - Why the in-helper option was chosen over the call-site option.
  - All 6 regression tests added (file + describe name + it name).
  - Verification commands run and their results.
  - Reference back to `.planning/debug/edge-collision-off-by-one.md` and the UAT.md gap entry.
  - Note that this also pre-empts future reconcile-churn once anti-cheat (Phase 06.8) re-enables the self-player snapshot path.
  </output>

  ## Plan 07 (protocol-build pre-flight DX hardening)
  ---
  phase: 06.7
  plan: 07
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - apps/server/package.json
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-SRV-14
  tags: [tooling, doctrine, gap-closure, protocol-build-preflight]
  must_haves:
    truths:
      - "`pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` (and any other integ test that depends on @rebno/protocol's compiled dist/) automatically builds @rebno/protocol BEFORE the test run, eliminating the stale-build false positive class of failures"
      - "The HUMAN-UAT operator runbook explicitly documents the protocol-build pre-flight requirement so manual UAT operators do not re-hit Test 5's `expected: 4400 actual: 524` false positive"
      - "The pre-flight build is fast (< 5 s for a no-op rebuild) and runs unconditionally before integ tests — no mtime heuristics that could miss edits"
      - "All other pre-existing apps/server scripts (build, test, test:integration, test:full, typecheck, migrate, dev, start) continue to work; only the integ-test entry points pick up the new pretest hook"
    artifacts:
      - path: "apps/server/package.json"
        provides: "pretest:integration / pretest:full hooks that build @rebno/protocol before running integ tests"
        contains: "pnpm -C ../../packages/protocol build"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator doctrine note at the top of the runbook citing the stale-protocol-build false positive lesson and the required pre-flight command"
        contains: "stale-protocol-build"
    key_links:
      - from: "any change to packages/protocol/src/*"
        to: "apps/server/test/protocol-v4-handshake.integ.test.ts (and other consumers of @rebno/protocol's dist/)"
        via: "apps/server/package.json `pretest:integration` / `pretest:full` npm-lifecycle hook"
        pattern: "pretest:integration"
  ---

  <objective>
  Eliminate the "stale @rebno/protocol build artifact" class of false-positive
  integ-test failures by adding an automatic pre-flight `pnpm -C packages/protocol build`
  to apps/server's integ-test entry points, plus a doctrine note in the
  HUMAN-UAT runbook for operators running tests by hand.

  Source: 06.7-UAT.md Test 5 note + Resolved (false positive) section:
    "Operator process improvement: ensure pnpm -C packages/protocol build runs
     before integ tests after any protocol schema/version change."

  The Test 5 first-run failure (`expected: 4400 actual: 524` +
  'seat reservation expired') was diagnosed as a stale @rebno/protocol build
  artifact, NOT a real defect — but it cost ~20 min of operator triage time
  and was already flagged in `.planning/debug/v3-rejection-wrong-close-code.md`
  as a recurring trap. This plan automates the fix in the test runner and
  documents the doctrine for manual operators.

  Purpose: developer-experience + UAT-reliability hardening. Prevents
  recurrence of a known false-positive class in a phase where REQ-CLI-08 hard
  milestone hangs on operator UAT signoff.

  Output: One atomic commit adding `pretest:integration` + `pretest:full` npm
  lifecycle hooks to apps/server/package.json, plus a "Pre-flight" section at
  the top of 06.7-HUMAN-UAT.md.
  </objective>

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

  <context>
  @.planning/STATE.md
  @CLAUDE.md
  @.planning/debug/v3-rejection-wrong-close-code.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  @apps/server/package.json
  @packages/protocol/package.json

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From apps/server/package.json (current scripts block — line 8-17):
  ```
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -b",
    "start": "node dist/index.js",
    "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
    "test:integration": "vitest run integ.test",
    "test:full": "vitest run",
    "typecheck": "tsc --noEmit",
    "migrate": "node dist/scripts/run-migrations.js"
  }
  ```

  npm/pnpm lifecycle: a script named `preX` runs automatically before `X`.
  `pretest:integration` runs before `test:integration`; `pretest:full` runs
  before `test:full`. The plain `test` script does NOT need a hook because it
  excludes integ tests.

  Relative path from apps/server/ to packages/protocol/:
    apps/server/  →  ../../packages/protocol/
  Verified: pnpm workspace layout at repo root, see pnpm-workspace.yaml.

  From .planning/debug/v3-rejection-wrong-close-code.md (resolution steps,
  line 93 — the exact command sequence that was the missing pre-flight):
  ```
  pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts
  ```
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="false">
    <name>Task 1: Add pretest:integration + pretest:full hooks to apps/server/package.json</name>
    <files>apps/server/package.json</files>
    <behavior>
      - Read current apps/server/package.json scripts block.
      - Insert two new entries directly above `test:integration`:
          `"pretest:integration": "pnpm -C ../../packages/protocol build",`
          `"pretest:full": "pnpm -C ../../packages/protocol build",`
        (the `pre*` lifecycle ensures these run automatically before
        `test:integration` and `test:full` respectively when invoked via pnpm.)
      - Leave the plain `"test"` script unchanged — it explicitly excludes
        integ tests, so a protocol rebuild adds no value and would slow down
        the inner-loop unit-test run.
      - Verify JSON is valid after edit: `pnpm -C apps/server pkg get scripts`.
      - Smoke the new pretest hook: from a state where packages/protocol/dist
        is intentionally stale (e.g., touch a source file under
        packages/protocol/src/ then run nothing), invoke
        `pnpm -C apps/server test:integration` and confirm the protocol build
        runs first.
          Acceptance: the integ-test command output shows the
          `> @rebno/protocol@... build` line BEFORE the vitest output.
          (If the executor cannot easily induce a stale-dist condition, it is
          sufficient to invoke `pnpm -C apps/server test:integration` and grep
          the output for the protocol-build line — it will run unconditionally
          thanks to `tsc -b`'s incremental check.)
    </behavior>
    <action>
      Edit apps/server/package.json via the Edit tool. Use a single `old_string`
      of the current scripts block and a `new_string` that inserts the two new
      lines in correct JSON order. Preserve trailing-comma discipline:
        ```
        "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
        "pretest:integration": "pnpm -C ../../packages/protocol build",
        "test:integration": "vitest run integ.test",
        "pretest:full": "pnpm -C ../../packages/protocol build",
        "test:full": "vitest run",
        ```
      Run `node -e "JSON.parse(require('fs').readFileSync('apps/server/package.json','utf8'))"` to confirm parse.
      Run `pnpm -C apps/server test:integration` and capture the first ~30 lines of output.
    </action>
    <verify>
      <automated>cd apps/server && node -e "const s = JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts; if (!s['pretest:integration'] || !s['pretest:full']) { console.error('missing pretest hooks'); process.exit(1); } console.log('hooks OK')"</automated>
      <automated>pnpm -C apps/server test:integration 2>&1 | grep -E "^(> @rebno/protocol|PASS|FAIL)" | head -5</automated>
    </verify>
    <done>
      apps/server/package.json contains both `pretest:integration` and `pretest:full` scripts that invoke `pnpm -C ../../packages/protocol build`. Running `pnpm -C apps/server test:integration` builds @rebno/protocol first, then runs vitest. Existing scripts (test, build, dev, start, typecheck, migrate) are unchanged.
    </done>
  </task>

  <task type="auto" tdd="false">
    <name>Task 2: Add Pre-flight doctrine section to 06.7-HUMAN-UAT.md</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <behavior>
      - Read the current HUMAN-UAT runbook.
      - Insert a new `## Pre-flight (run before any test)` section directly after the YAML frontmatter and the first paragraph/heading, but BEFORE the test enumeration. Content:
          ```
          ## Pre-flight (run before any test)

          After ANY edit to `packages/protocol/src/*`, rebuild the protocol
          package before running integ tests OR manual UAT against staging:

              pnpm -C packages/protocol build

          For `apps/server` integ tests (`pnpm -C apps/server test:integration`
          or `test:full`), this build is now automatic via `pretest:integration`
          / `pretest:full` hooks (Plan 06.7-07). The command is documented here
          for manual operators running `vitest run` directly or for
          cross-package consumers (apps/client, etc.).

          **Why:** UAT Test 5 (PROTOCOL_VERSION 3 client rejection) first-run
          reported `expected: 4400 actual: 524` + 'seat reservation expired'
          failures that were diagnosed as a stale @rebno/protocol build
          artifact, NOT a real defect. After `pnpm -C packages/protocol build`,
          all 3 tests passed at HEAD `9816275`. See
          `.planning/debug/v3-rejection-wrong-close-code.md`.
          ```
      - Do NOT modify any existing test entries.
      - Add a traceable-reqs `[doc->REQ-SRV-14]` tag on a line of its own at
        the bottom of the new section (REQ-SRV-14 is the protocol-version /
        handshake req — confirm in REQUIREMENTS.md before tagging; if a more
        apt req exists for "testing doctrine", use that instead).
    </behavior>
    <action>
      Use the Edit tool with a unique anchor (the YAML frontmatter closing `---` + the first H1 line of HUMAN-UAT.md) to insert the new section without disturbing existing content. Run `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` to verify insertion.
    </action>
    <verify>
      <automated>grep -n "Pre-flight\|stale-protocol-build\|pretest:integration" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
      <automated>pnpm trace:check</automated>
    </verify>
    <done>
      06.7-HUMAN-UAT.md has a "Pre-flight (run before any test)" section between the frontmatter and the first test entry, documenting the manual protocol-build command, the automatic hook for apps/server, the "why" with reference to debug session, and a `[doc->REQ-SRV-14]` (or chosen alternative) tag. `pnpm trace:check` is green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | developer workstation → npm-lifecycle hook | The new `pretest:integration` hook runs `pnpm -C ../../packages/protocol build`, which executes `tsc -b`. tsc is already a trusted dev dep; no new attack surface. The relative path `../../packages/protocol` is resolved within the pnpm workspace and cannot escape it under normal repo structure. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-07-01 | Tampering | malicious modification of pretest hook to inject arbitrary command | accept | apps/server/package.json is under version control; any change to scripts is visible in git diff and PR review. Same posture as every other npm script. |
  | T-06.7-07-02 | Denial of Service | pretest hook adds latency to inner-loop testing | mitigated | Hook applies only to `test:integration` and `test:full`. Plain `test` script (the inner-loop fast path that excludes integ tests) is unchanged. tsc incremental rebuild on a clean tree is ~1-2 s; acceptable for integ-test runs that already take 3+ s. |
  | T-06.7-07-03 | Tampering | doctrine note in HUMAN-UAT.md being skipped by an operator | accept | Plan does not require operators to read the doctrine — automated hook covers the apps/server path. The doctrine is a safety net for cross-package consumers and for manual `vitest` invocation. |
  </threat_model>

  <verification>
    - `pnpm -C apps/server pkg get scripts` — output includes `pretest:integration` and `pretest:full`.
    - `pnpm -C apps/server test:integration` — runs `@rebno/protocol build` first (visible in stdout), then runs vitest; all integ tests pass.
    - `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — returns a match.
    - `pnpm trace:check` — green.
    - No regression on other apps/server scripts: `pnpm -C apps/server typecheck` still green; `pnpm -C apps/server build` still green; `pnpm -C apps/server test` (unit only) still green.
  </verification>

  <success_criteria>
  - One commit lands with message `chore(06.7): auto-build @rebno/protocol before apps/server integ tests + UAT doctrine`.
  - `pnpm -C apps/server test:integration` invoked from a clean checkout produces a `@rebno/protocol build` line in its output BEFORE vitest starts.
  - 06.7-HUMAN-UAT.md has a Pre-flight section.
  - `pnpm trace:check` green.
  - Test 5 false-positive class will not recur on the apps/server integ path.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-07-SUMMARY.md` documenting:
  - The two pretest hook entries added.
  - The HUMAN-UAT doctrine section added (with line numbers).
  - A short rationale why pretest hooks were preferred over a root-level alias or a `prepare` script.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/v3-rejection-wrong-close-code.md` and the UAT.md "Resolved (false positive)" entry.
  - A note that this is a DX/UAT-reliability hardening plan — no production code or wire-protocol change.
  </output>


  ## Review Instructions

  For each plan (05, 06, 07) provide:

  1. **Summary** — One-paragraph assessment.
  2. **Strengths** — bullet points.
  3. **Concerns** — bullet points with severity HIGH/MEDIUM/LOW.
  4. **Suggestions** — specific, actionable.
  5. **Risk Assessment** — LOW/MEDIUM/HIGH overall with justification.

  Then add a **Cross-Plan Section** covering:
  - Whether the 3 plans together fully close the UAT.md gap entries (4 gaps total: 3 blocker + 1 minor; Test 5 is already a false positive resolved)
  - Whether wave parallelism is safe (Plan 05 = apps/client, Plan 06 = packages/game-logic, Plan 07 = apps/server + .planning docs — claim is "disjoint files, safe to parallelize")
  - Whether anything is missing that should be a Plan 08 (e.g., a test that pins the 1-px reconcile delta won't return when Phase 06.8 anti-cheat re-enables self-player snapshot, a guard against the inputDispatcher.axisX() type drift mentioned as a Plan 05 nit)
  - Hard Rule 1 carve-out compliance (movement client-authoritative, everything else server-authoritative)

  Be terse and concrete. No filler. Output markdown.

first_user_message:
  # Cross-AI Plan Review Request — REBNO Phase 06.7 Gap-Closure Plans 05/06/07

  You are reviewing THREE gap-closure plans for Phase 06.7 of REBNO (a Chrome rebuild of an early-2000s GameMaker 5.3a multiplayer game).

  The phase's first 4 plans (01-04) shipped a CLIENT-AUTHORITATIVE movement model: clients send `position_update` intents at 30 Hz; server stores/broadcasts without semantic validation. Phases 01-04 were merged at HEAD 9816275. Operator HUMAN-UAT then found 2 blocker-class issues + 1 minor issue. Plans 05/06/07 close those gaps.

  Focus your review on whether Plans 05/06/07 actually close the gaps without introducing new defects, scope creep, or coverage holes. Plans 01-04 are out of scope (already executed).

  ## Project Context (CLAUDE.md hard rules + extracted constants)

  # CLAUDE.md — REBNO Project Instructions

  ## Project

  **REBNO** — ground-up rebuild of *BN Online* (a late-1990s/early-2000s GameMaker 5.3a multiplayer game) on a modern web stack. Target: Chrome users walk around and chat in real time, then full feature parity with the original.

  **Core Value:** Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.

  ## Source-of-Truth Files

  Everything depends on two files in `legacy/open-source-release/`:

  - `BN Online Client 5-8.gmd` — latest client revision
  - `BN Online Master 5-4.gmd` — latest server revision

  Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only. Do not target them as primary inputs.

  ## Repo Layout

  | Path | Purpose |
  |---|---|
  | `decomp/wiki/` | 17-doc reverse-engineering knowledge base for GameMaker 5.3a — read these before reasoning about extraction, the `.gmd` format, the 39dll wire protocol, or `.bno`/`.bnb`/`.bnu` save data |
  | `legacy/open-source-release/` | Authoritative source `.gmd` files (the two listed above) |
  | `legacy/source-archive/`, `legacy/servers/` | Older revisions — reference only |
  | `legacy/unity-project/`, `legacy/maya-project/` | Prior abandoned remake attempts and original Maya assets — informative, not authoritative |
  | `legacy/audio/`, `legacy/open-source-release-extras/` | Original audio + assorted artifacts |
  | `.planning/` | GSD workflow state — see below |
  | `.planning/codebase/` | 7 docs mapping the existing brownfield codebase |
  | `.planning/research/` | 5 docs of project-level research (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY) |

  ## Tech Stack (decided)

  - **Server**: Node 22 + TypeScript + Colyseus 0.17 + `ws` + Better-Auth + argon2id + better-sqlite3 + Litestream
  - **Client**: TypeScript + Vite — engine = Phaser 3 (preferred) OR PixiJS (fallback). Final choice locked by ADR at end of Phase 2.
  - **Transport**: WebSocket binary frames; authoritative server; clients send intent
  - **Persistence**: SQLite + Litestream → Tigris bucket on Fly.io. Postgres only if Phase 3 surfaces a relational requirement.
  - **Hosting**: Fly.io single machine, persistent volume, <50 CCU target
  - **Monorepo**: pnpm workspaces. Shared packages: `protocol` (wire types + codec) and `game-logic` (pure deterministic simulation, runs in Node + browser identically).

  See `.planning/research/STACK.md` for version pins, alternatives matrix, and Fly.io specifics.

  ## Extracted Constants (load-bearing — never assume otherwise)

  | Constant | Value | Source of truth |
  |---|---|---|
  | Floor tile dimensions | **44 × 40 px** (W × H) | `extracted/client-5-8/sprites/0023-Tile1/meta.json` |
  | Room tick rate | 30 steps/sec | `extracted/client-5-8/rooms/*/meta.json` `speed: 30` |
  | View / camera viewport | 640 × 480 px | `extracted/client-5-8/rooms/*/meta.json` `views[0].viewW/viewH` |
  | BNCentral room dimensions | 8000 × 6400 px | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` |
  | MVP synthetic room (20×20 tiles) | 880 × 800 px | derived: 20·44 × 20·40 |

  **NEVER hard-code 32×32 or any other tile size.** GameMaker's editor defaults are misleading; BNO's tile pitch is 44×40. Every renderer, collision grid, asset-pipeline atlas slicer, and test-room generator must read these from extracted `meta.json` or use the constants above. See [`docs/extracted-engine/scene-room-model.md`](docs/extracted-engine/scene-room-model.md) "Canonical floor tile" for full derivation.

  ## Coordinate Conventions (D-63 — Plan 06.4)

  The legacy GameMaker engine and our Phaser renderer use different sprite-origin
  conventions. Player-attached effects (TeleIn, TeleOut, HexportIn/Out, ncol*,
  jokershell, watching) must convert between them.

  | Engine | Navi sprite origin | Effect placement |
  |--------|---------------------|------------------|
  | Legacy GM (5.3a) | `(0, 0)` = top-left (head) | effect drawn at legacy `(x, y)` = Navi top-left |
  | Phaser (REBNO)   | `(0.5, 1)` = bottom-center (feet) | sprite.x = Navi center, sprite.y = visible feet line (NOT sprite-rect bottom) |

  **Convention shift pin:**
  - `legacy_x = phaser_x - NAVI_WIDTH_PX/2 = phaser_x - 18`
  - `legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y = phaser_y - 46`

  `NAVI_VISIBLE_FEET_Y = 46` is the bbox-bottom of NaviStandD, NOT 48 (sprite-rect
  height). The two-pixel difference (rows 46-47 in the sprite rect) is the
  transparent foot-shadow region below the visible feet — including those rows
  in the conversion places effects two pixels too low (operator UAT 2026-05-14).

  **D-55c round-2 (06.4 round-2 UAT 2026-05-15 on commit 63b022f) effect-nudge:**
  `EFFECT_NUDGE_X_PX = -1`, `EFFECT_NUDGE_Y_PX = +2` are added inside
  `phaserOriginForLegacyPlayerAttached`. Phaser origin convention: increasing
  originX shifts the rendered sprite LEFT (`sprite-left = sprite.x - originX * width`);
  increasing originY shifts the rendered sprite UP. Cycle-5 set
  `EFFECT_NUDGE_X_PX = +1` (originX = 34/64) to correct a 1 px right deviation
  on staging 5fbb357; round-2 UAT on commit 63b022f reported TeleIn 2 px too
  LEFT — i.e., cycle-5 over-corrected. To shift the sprite 2 px RIGHT we
  DECREASE originX-numerator by 2: net `EFFECT_NUDGE_X_PX = -1`, originX = 32/64
  = 0.5. `EFFECT_NUDGE_Y_PX` stays at +2 (vertical good per operator).
  Round-2 TeleIn-variant origins are `[32/64, 90/100] = [0.5, 0.9]`. The bare
  `NAVI_WIDTH_PX = 36` / `NAVI_VISIBLE_FEET_Y = 46` constants stay anchored to
  the extracted meta.json — drift detection against
  `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` continues to assert
  those source-of-truth values.

  **Canonical entry:** `apps/client/src/render/legacy-origin.ts` exports
  `phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]`.
  All player-attached effect ports MUST use this helper. Inline origin math in
  `apps/client/src/render/` is rejected by code review (HARD gate 5).

  **Source of truth:** `extracted/client-5-8/sprites/0000-NaviStandD/meta.json`
  (width=36, height=48, bboxBottom=46). The atlas-mvp.json frame metadata is
  asserted against these constants by the unit test
  `apps/client/src/__test__/legacy-origin.test.ts` (drift-detection — fails CI
  if atlas regeneration produces different Navi metrics).

  [doc->REQ-CLI-04] [doc->REQ-CLI-08]

  ## Hard Rules

  1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]
  2. **No faithful port of plaintext passwords.** Original `localList.txt` plaintext archive is a security incident, not a feature. argon2id from packet 1.
  3. **No faithful port of "run clipboard as superuser" admin.** Original RCE-as-a-feature admin model is replaced by a separate authenticated web UI in Phase 7.
  4. **`.bno` / `.bnb` / `.bnu` parsing requires extracted GML first.** These formats only exist as the call order of `file_bin_*` ops in the project's own GML. Do NOT hex-inspect; extract Phase 1, derive Phase 3, parse Phase 4+.
  5. **39dll wire protocol = call order.** Same principle. Reverse it from `write*`/`read*` sequences in extracted Master GML, not from packet captures.
  6. **Extract → document → rewrite, in that order.** Phases 1-3 produce schemas; Phases 4+ consume them. No new TypeScript before Phase 4.
  7. **Modern decompilers (UTMT, Altar.NET) cannot read GM 5.3a.** Use era-appropriate tools: GM Decompiler v2.1, GMD-Recovery, LateralGM, in a WinXP VM. See `decomp/wiki/13-modern-tool-incompat.md`.
  8. **Repo stays private through Phase 7.** BNO is Capcom-derived (Mega Man Battle Network). Legal/IP path deferred. Verify `legacy/` is not pushed publicly until cleared.

  ## GSD Workflow

  This project uses the GSD (Get Shit Done) phased-execution system. Workflow state lives in `.planning/`:

  - `PROJECT.md` — project context, requirements, constraints, decisions (read first)
  - `REQUIREMENTS.md` — 49 v1 requirements with REQ-IDs, traceability table
  - `ROADMAP.md` — 7 phases with goals, requirement mapping, success criteria
  - `STATE.md` — current phase + status (read to know where work is)
  - `config.json` — workflow preferences (mode=yolo, granularity=standard, parallelization=on, model_profile=quality, research=on, plan_check=on, verifier=on)
  - `research/` — project-level research outputs (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY)
  - `codebase/` — codebase map (STACK, INTEGRATIONS, ARCHITECTURE, STRUCTURE, CONVENTIONS, TESTING, CONCERNS)

  ### Workflow Discipline

  - **Per-phase flow**: `/gsd-discuss-phase N` → `/gsd-plan-phase N` → `/gsd-execute-phase N` → `/gsd-verify-work N`
  - **Atomic commits**: Each phase commits its artifacts immediately. Use `gsd-sdk query commit "msg" <files>`.
  - **Phases 1-3 do NOT parallelize with Phase 4+.** Within a phase, parallel plan execution is fine.
  - **Hard milestone**: CLI-08 in Phase 6 — two players move + chat over deployed server. Entire rebuild gates here.
  - **Research-flagged phases** (1, 3, 7) need deeper per-phase research at planning time.

  ## Phase Roadmap (high level)

  1. **Extraction** — both `.gmd` → diffable per-resource trees (8 reqs)
  2. **Client Engine Documentation** — document client engine + Phaser/Pixi ADR (4 reqs)
  3. **Server Documentation & Schemas** — reverse 39dll, save formats, persistence ADR, closed parity checklist (6 reqs)
  4. **Server Rebuild (MVP)** — Node + Colyseus authoritative server for movement+chat slice (12 reqs)
  5. **Deploy** — Fly.io + Litestream + CI/CD + restore runbook (8 reqs)

  ## UAT Findings (06.7-UAT.md)
  ---
  status: diagnosed
  phase: 06.7-network-model-client-trust-fall-trigger
  source: [06.7-01-SUMMARY.md, 06.7-02-SUMMARY.md, 06.7-03-SUMMARY.md, 06.7-04-SUMMARY.md, 06.7-HUMAN-UAT.md]
  started: 2026-05-17T10:18:59Z
  updated: 2026-05-17T10:34:30Z
  ---

  ## Current Test

  [testing complete]

  ## Tests

  ### 1. Cold Start Smoke Test
  expected: Kill running server/client. Start fresh. Server boots clean, client handshake succeeds at PROTOCOL_VERSION=4, join BNCentral returns live state.
  result: pass
  note: Operator connected two clients to staging successfully; cold-start path implicitly validated by subsequent multiplayer test.

  ### 2. Diagonal-stop drift bug closed (D-02a)
  expected: Log in to BNCentral spawn. Hold W+D for 2 s for up-right diagonal. Release both keys simultaneously. Sprite stops at its current pixel position with no horizontal shift and no ~10 px snap right or left.
  result: pass
  reported: "diagonal stop drift fixed"

  ### 3. Dropped-packet hitching closed (D-02b)
  expected: DevTools → Network → Throttling = Slow 3G (or custom ~5% loss / 150 ms RTT). Hold W to run north 5 s. Continuous smooth movement.
  result: pass
  reported: "no more dropped-packet hitching, silky smooth for local movement"

  ### 4. Two-player smoke (REQ-CLI-08 hard milestone regression)
  expected: Operator A + B in two browsers join BNCentral. Each sees the other's sprite and smooth motion (no rubber-band, no drift), running animation plays, velocity is applied. Chat appears with correct origin.
  result: issue
  reported: "client of 2nd joiner doesn't see any remote player position or sprite updates; client of 1st joiner does see player position updates, but they're super slow, inconsistent timing, facing direction comes through but never sprite change (running anim never plays), velocity is never applied; chat works"
  severity: blocker

  ### 5. PROTOCOL_VERSION 3 client rejection — AUTOMATED COVERAGE (D-12)
  expected: `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` exits 0. v3 rejected with close-code 4400 / PROTOCOL_VERSION_MISMATCH; v4 accepted.
  result: pass
  note: First run produced 524≠4400 + 'seat reservation expired' failures — diagnosed as stale packages/protocol build artifact. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275 (3.34s, 0 failures). Server correctly throws ServerError(4400, "PROTOCOL_VERSION_MISMATCH ...") at apps/server/src/RebnoRoom.ts:394-398.

  ### 6. Rollback drill (D-14)
  expected: Confirm documented rollback target references the four Phase 06.7 plan commits.
  result: pass
  note: Verified in 06.7-04-SUMMARY.md "Rollback Target" section — lists Plan 01 (1ba1fe6), Plan 01 summary (086bf71), Plan 02 (f9b02e3), Plan 03 (8ba0f9b), Plan 04 docs commit. Four production commits accounted for.

  ### 7. Idle-cadence steady 30 Hz network confirm (D-06)
  expected: Idle BNCentral 5 s with WS frame panel open. ~150 position_update frames (120–180 range), each with vx=0/vy=0, no gaps >100 ms.
  result: skipped
  reason: Operator did not run DevTools WS frame measurement during this UAT pass. Automated coverage in apps/client/src/__test__/position-dispatcher.test.ts is the standing guard; manual measurement deferred until Test 4 blocker resolved (remote-sync issues may also indicate dispatcher cadence problems).

  ## Summary

  total: 7
  passed: 4
  issues: 1
  pending: 0
  skipped: 1
  blocked: 0
  new_regressions: 1
  gaps_diagnosed: 5

  ## Gaps

  - truth: "Each peer sees the other's player position, velocity, facing, and animation state in near-real-time"
    status: failed
    reason: "User reported: client of 2nd joiner doesn't see ANY remote player position or sprite updates from 1st; 1st sees super slow inconsistent updates from 2nd"
    severity: blocker
    test: 4
    root_cause: "RC #1 — Wire-schema vs runtime-timestamp mismatch. cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) declares monotonic_at_ms as .int(); PositionDispatcher passes Phaser fractional rAF _time arg unmodified (position-dispatcher.ts:30-46, GameScene.ts:1193 sendTick(_time)). Every frame fails zod parse → server logs invalid_intent and silently drops (onMessageHandlers.ts:315-324). Only frames landing on integer-ms boundaries pass — hence super-slow inconsistent timing. For 2nd joiner viewing 1st (mostly idle pre-join), nearly all updates dropped → sprite frozen at spawn → 'sees nothing'."
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 30-46 pass monotonic_at_ms through unmodified, unlike x/y/vx/vy which ARE rounded"
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Line 1193 passes Phaser fractional _time to sendTick"
      - path: "packages/protocol/src/intents.ts"
        issue: "Lines 125-137 .int() constraint on monotonic_at_ms (correct — wire contract)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        issue: "Unit tests use integer time args (sendTick(1), sendTick(1234)) — masked runtime drift from CI"
    missing:
      - "Apply Math.floor(monotonic_at_ms) at position-dispatcher.ts:42 (preserve u32-int wire contract)"
      - "Add regression test: dispatcher.sendTick(1234.567) → payload monotonic_at_ms === 1234"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player velocity (vx, vy) is applied to position interpolation/extrapolation"
    status: failed
    reason: "User reported: velocity is never applied"
    severity: blocker
    test: 4
    root_cause: "RC #2 — PredictionEngine.getLocalState() always returns vx=0, vy=0. step.ts:243-251 (with-input branch) hardcodes vx:0, vy:0 per BNO instant-set model (ACCEL=0, FRICTION=0). predictor.ts:113-114 propagates to localState. position-dispatcher.ts:37-38 Math.round(local.vx/vy) → always 0 on wire. Server echoes 0; remote renderer never animates."
    artifacts:
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 243-251 with-input branch returns vx:0, vy:0 unconditionally"
      - path: "apps/client/src/prediction/predictor.ts"
        issue: "Lines 113-114 propagate step's vx=0/vy=0 to localState"
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 37-38 read local.vx/vy which are always 0"
    missing:
      - "Inject axis-vector callback into PositionDispatcher (parallel to getFacing / getIsRunning)"
      - "Compute vx = round(axisX * RUN_SPEED_PX_PER_TICK), vy = round(axisY * RUN_SPEED_PX_PER_TICK) — mirror GameScene.ts:1199-1200 pattern used for local renderer's onSimulationTickLocal"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player sprite plays running animation when vx/vy != 0"
    status: failed
    reason: "User reported: facing comes through but sprite change never happens, running anim never plays"
    severity: blocker
    test: 4
    root_cause: "RC #3 — Remote-render reads vx/vy only, ignores broadcast anim_state. GameScene.ts:1213-1226 remote-loop calls playerRenderer.onSimulationTickRemote(sessionId, p.vx, p.vy, p.x, p.y); never consumes p.anim_state. PlayerRenderer.ts:463-470 derives isRunning = hypot(vx,vy) > 0 — with vx=0/vy=0 from RC #2, always Stand frame. Dispatcher DOES author anim_state correctly via packAnimState(facing, getIsRunning()) and PlayerState.anim_state IS @type-decorated; consumer just ignores it."
    artifacts:
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Lines 1213-1226 remote-sim-tick loop ignores p.anim_state"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        issue: "Lines 463-470 derive isRunning from vx/vy instead of consuming explicit anim_state"
    missing:
      - "Pass unpackAnimState(p.anim_state).running as new arg to onSimulationTickRemote (principled — server anim_state is single source of truth)"
      - "OR rely on RC #2 fix making vx/vy non-zero during movement (cheaper consequence of RC #2)"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline"
    status: failed
    reason: "User reported: right-edge and bottom-edge floor collision both stop one pixel farther away (on the respective axis) than before"
    severity: minor
    test: 4
    root_cause: "Boundary-convention mismatch between two collision paths in packages/game-logic/src/collision.ts. Client-side walkable-grid path (isFeetBboxWalkable line 172) treats feet-bbox right/bottom corner pixel as INCLUSIVE via Math.floor(px/tile_w); legacy polys path (feetBboxIntersectsAABB lines 69-72) treats it as EXCLUSIVE via strict feetMaxX > minX. The two paths produce stops differing by exactly 1 px on MAX axes (right, bottom) and agree on MIN axes (left, top) — matches operator report exactly. Pre-existed since Plan 06.1-02 (0c40432) but was masked: pre-06.7 ReconcileEngine.onServerSnapshot called predictor.applyServerSnapshot for self-player, replacing predicted position with server polys-resolved position. Phase 06.7 Plan 03 (8ba0f9b) added early-return for self-player in reconciler.ts:53-54 — removed corrective sink, exposed grid-path 1-px-short stop."
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        issue: "Line 172 — Math.floor in isFeetBboxWalkable causes inclusive-corner asymmetry (load-bearing line)"
      - path: "packages/game-logic/src/collision.ts"
        issue: "Lines 63-74 — feetBboxIntersectsAABB uses strict inequality (other half of mismatch)"
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 179-225 — branch selector routes client to grid path"
      - path: "apps/client/src/prediction/reconciler.ts"
        issue: "Lines 53-54 — isSelfPlayer early-return that exposed pre-existing asymmetry"
    missing:
      - "Normalize boundary conventions: make isFeetBboxWalkable's right/bottom corner sample exclusive of bbox boundary"
      - "Subtract 1 from right/bottom probe pixels at call sites step.ts:194-195 and step.ts:214-215 (intent explicit there); OR adjust inside isFeetBboxWalkable for right/bottom only"
      - "Add regression test pinning right-edge AND bottom-edge stop pixels against tile-boundary wall for known walkable grid"
    debug_session: ".planning/debug/edge-collision-off-by-one.md"

  ## Resolved (false positive)

  - truth: "PROTOCOL_VERSION 3 client rejection emits close-code 4400 with PROTOCOL_VERSION_MISMATCH message"
    status: resolved_no_fix
    reason: "Initial run reported AssertionError: expected 524 to be 4400. Diagnosed as stale packages/protocol build. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275. Server correctly throws ServerError(4400, 'PROTOCOL_VERSION_MISMATCH ...') at apps/server/src/RebnoRoom.ts:394-398; @colyseus/ws-transport preserves 4400 through to SDK MatchMakeError.code."
    severity: n/a
    test: 5
    debug_session: ".planning/debug/v3-rejection-wrong-close-code.md"
    note: "Operator process improvement: ensure pnpm -C packages/protocol build runs before integ tests after any protocol schema/version change."

  ## Debug Session 1 — two-player-remote-sync-broken.md
  ---
  status: diagnosed
  trigger: "Two-player smoke test FAILED post Phase 06.7 client-trust flip — 2nd joiner sees nothing from 1st; 1st sees 2nd's updates super slow with no anim change and no velocity applied"
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Three layered root causes — (#1) monotonic_at_ms .int() vs fractional Phaser timestamp rejects most frames; (#2) PredictionEngine always returns vx=0/vy=0 due to BNO instant-set model in step(); (#3) remote render ignores broadcast anim_state and re-derives anim from vx/vy (which are 0 from #2)
  test: Static code analysis of server handler + client dispatcher + remote-render + step()
  expecting: All three are visible in current code; tests do not catch them because mocks bypass each defect
  next_action: Return diagnosis to caller (find_root_cause_only mode)

  ## Symptoms

  expected: Two players in BNCentral both see each other's sprite positions, velocity-driven movement, running animation when other moves; chat works for both
  actual:
    - 2nd joiner sees NOTHING of 1st joiner's movement/sprite (sprite may not even be visible)
    - 1st joiner sees 2nd's updates SLOWLY with inconsistent timing
    - 1st joiner: facing updates work, but sprite never animates (running anim doesn't play)
    - 1st joiner: velocity (vx, vy) is never applied to remote sprite rendering
    - Chat works both directions (so connection + broadcast generally healthy)
  errors: None (no console errors, no test errors — purely operator observation)
  reproduction: HUMAN-UAT Test 4. Two browsers, two accounts, join BNCentral, walk W/D/S/A square, each observes other
  started: After Phase 06.7 client-trust flip (plans 02 + 03). Pre-06.7 worked.

  ## Eliminated

  - hypothesis: PlayerState fields not @type-decorated for sync (so anim_state / sprite_override never reach peers)
    evidence: packages/protocol/src/state.ts:57-58 — both fields ARE @type('number') / @type('string'). Colyseus state-sync will broadcast them.
    timestamp: investigation

  - hypothesis: onJoin fails to deliver existing-player state to late-joiner
    evidence: Colyseus 0.17 delivers initial state-sync containing all entries in state.players to a new client automatically (no app code needed). The onMessageHandlers PlayerState fields are server-set in onJoin BEFORE state.players.set, so a late-joiner WILL receive 1st-joiner's PlayerState in their initial state-sync.
    timestamp: investigation

  - hypothesis: PositionDispatcher never fires for 2nd joiner (dispatcher not attached)
    evidence: GameScene.onLocalJoin (apps/client/src/scenes/GameScene.ts:676) creates the dispatcher when `!this.positionDispatcher && this.room && this.prediction`. Local onLocalJoin runs unconditionally; dispatcher is wired before update() loop calls sendTick.
    timestamp: investigation

  - hypothesis: Colyseus $(player).onChange() doesn't register correctly during initial state-sync
    evidence: bindHandlers in colyseus-client.ts:251 registers $(player).onChange synchronously inside players.onAdd; per Colyseus 0.17 docs onAdd auto-fires for existing items, registering listeners.
    timestamp: investigation

  - hypothesis: server `applyToColyseusState` overwrites client-written PlayerState fields
    evidence: packages/game-logic/src/step.ts:68-83 — when input is undefined (always the case in RebnoRoom.tickLoop now), step() preserves x/y/vx/vy/last_input_seq exactly. applyToColyseusState rewrites those identical values — no overwrite.
    timestamp: investigation

  ## Evidence

  - timestamp: investigation
    checked: packages/protocol/src/intents.ts line 125-137 (cPositionUpdateSchema definition)
    found: monotonic_at_ms uses `.finite().int().min(0).max(0xffffffff)` — STRICT integer required. Plan 01-SUMMARY notes this was deliberate ("closes codex review concern #7"). x/y/vx/vy all `.int()` too.
    implication: Any fractional monotonic_at_ms causes zod parse failure → server logs `invalid_intent` and DROPS the frame.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:30-46 (PositionDispatcher.sendTick)
    found: `monotonic_at_ms` is passed through to the payload UNMODIFIED. x/y/vx/vy are Math.round'd. monotonic_at_ms is NOT rounded.
    implication: Whatever the caller passes goes directly to the wire.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1193 (sim-tick call to sendTick)
    found: `this.positionDispatcher?.sendTick(_time)` — passes Phaser's `_time` (the first arg to `update(_time, dt)`), which is the rAF/performance.now-derived high-resolution timestamp. In modern browsers this is fractional ms (e.g. 1234.567).
    implication: Every position_update frame fails `monotonic_at_ms.int()` UNLESS the browser returns an integer ms (rare modes: 1ms-clamped non-cross-origin contexts, throttled background tabs). Hence "super slow with inconsistent timing" — only the occasional integer-tagged frame is accepted.

  - timestamp: investigation
    checked: apps/client/src/__test__/position-dispatcher.test.ts
    found: All unit tests call `dispatcher.sendTick(1)`, `sendTick(2)`, `sendTick(1000)`, `sendTick(1234)` — INTEGER arguments. Tests never exercise the realistic fractional-timestamp path.
    implication: The defect ships green-tested. Server handler integration tests also use synthetic integer timestamps. Drift between test fixture and runtime call-site.

  - timestamp: investigation
    checked: packages/game-logic/src/step.ts:243-251 (with-input branch of step)
    found: After integrating displacement, `newPlayers.set(...)` writes `vx: 0, vy: 0` ALWAYS. Comment line 240-242: "BNO has no velocity accumulation (ACCEL=0, FRICTION=0 — instant set/stop). vx/vy are kept at 0 in the BNO-faithful model".
    implication: PredictionEngine.predictTick() consumes step() and assigns `this.localState = next.players.get(...)`. After any predict tick, localState.vx and localState.vy are 0. PositionDispatcher.sendTick reads `local.vx, local.vy` → always sends vx=0, vy=0 on the wire.

  - timestamp: investigation
    checked: apps/server/src/onMessageHandlers.ts:354-360 (position_update handler)
    found: When a frame DOES pass zod (rare due to issue #1), server writes `player.vx = parsed.data.vx` (=0) and `player.vy = parsed.data.vy` (=0). PlayerState.vx/vy stays at 0 indefinitely for every player.
    implication: Server's authoritative PlayerState.vx/vy is always 0. Colyseus state-sync broadcasts vx=0, vy=0 to every peer.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1213-1226 (sim-tick remote loop)
    found: For each non-local player in state.players, calls `playerRenderer.onSimulationTickRemote(sid, p.vx ?? 0, p.vy ?? 0, p.x ?? 0, p.y ?? 0)`. Does NOT consume `p.anim_state`. Comment line 1209-1212: "Remote players consume PlayerState.vx / .vy directly. Pre-06.7 derived velocity from held-axis fields, but server Path A no longer writes those fields; position_update writes vx/vy as px/tick."
    implication: Remote-render derives running flag from vx/vy. Both are always 0 (from issue #2). deriveFrameWithAuthoritativeFacing returns a Stand-* frame → running anim NEVER plays for any remote player. The broadcast PlayerState.anim_state (which IS correctly authored per-tick by the dispatcher's `packAnimState(facing, this.getIsRunning())` from input axes — bug-free at the dispatcher) is COMPLETELY UNUSED on the consumer side.

  - timestamp: investigation
    checked: apps/client/src/render/PlayerRenderer.ts:434-470 (onSimulationTickRemote internals)
    found: deriveFrameWithAuthoritativeFacing(renderVx, renderVy, ...) — when renderVx=0 and renderVy=0, isRunning=false → Stand frame. Confirmed by line 476 "isFirstRemote → frameKey published as 0000-NaviStandD_000-style key".
    implication: Even with correct broadcast anim_state, the renderer ignores it.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:38 (getIsRunning callback origin)
    found: GameScene.ts:681-685 builds getIsRunning from inputDispatcher.axisX()/axisY() ≠ 0. This correctly reports running based on user input — the running-flag bit in anim_state IS authored correctly. The dispatcher's anim_state byte on the wire is correct.
    implication: anim_state is correct end-to-end on the wire. The defect is that the consumer never reads it.

  ## Resolution

  root_cause:
    Three layered root causes account for ALL observed symptoms:

    #1 (PRIMARY — explains "super slow, inconsistent timing" + "2nd joiner sees nothing"):
    cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) requires
    `monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)` — strict integer.
    GameScene.ts:1193 calls `positionDispatcher?.sendTick(_time)` passing Phaser's `_time`
    (a fractional high-resolution timestamp from rAF). PositionDispatcher passes the value
    through unmodified to the wire payload. Server zod-parses → fails `.int()` → drops the
    frame with `invalid_intent` warn. Effectively 100% of position_updates are rejected
    except in rare browser modes where performance.now() returns 1-ms-clamped integers
    (background tabs, certain incognito modes). On staging this produces the
    "super slow, inconsistent timing" symptom. For the 2nd joiner viewing the 1st (who
    was at spawn and hadn't moved much before the 2nd joined), most/all updates are
    dropped → 1st's sprite appears frozen at spawn coords → "sees nothing of 1st".

    #2 (explains "velocity is never applied"):
    PredictionEngine.getLocalState() returns the post-predictTick `localState`, which is
    the `PlayerSim` returned by step(). Per packages/game-logic/src/step.ts:243-251
    (with-input branch) and BNO instant-set model, step() ALWAYS returns vx=0, vy=0.
    PositionDispatcher.sendTick (apps/client/src/prediction/position-dispatcher.ts:37-38)
    reads `local.vx, local.vy` from getLocalState → always sends vx=0, vy=0 on the wire.
    Server stores vx=0, vy=0 in PlayerState. Colyseus broadcasts vx=0, vy=0 to peers.

    #3 (explains "running animation never plays"):
    GameScene.ts:1213-1226 sim-tick remote loop reads `p.vx ?? 0, p.vy ?? 0` (which from
    #2 are always 0 even when the source position_update wasn't rejected) and ignores
    `p.anim_state` entirely. PlayerRenderer.onSimulationTickRemote derives the running
    flag from renderVx/renderVy → Stand frame always. The broadcast anim_state byte
    (which IS correctly authored by PositionDispatcher.sendTick line 40
    `packAnimState(facing, this.getIsRunning())`) is unused on the consumer side.

  fix:
    Fixes are NOT applied — caller requested find_root_cause_only.

    Suggested fix direction (for Plan 06.8 or 06.7-hotfix):

    Fix #1 — choose ONE:
      (a) Coerce monotonic_at_ms to integer at the dispatcher boundary:
          apps/client/src/prediction/position-dispatcher.ts line 42 →
          `monotonic_at_ms: Math.floor(monotonic_at_ms)`. Simplest, minimal-blast-radius.
      (b) Loosen the schema in packages/protocol/src/intents.ts line 135 to
          `z.number().finite().nonnegative().max(0xffffffff)` (drop `.int()`). Revisits
          codex review concern #7 — only acceptable if the u32 binary contract is
          relaxed for the JSON-over-msgpack wire path.
      Preferred: (a). Keeps the strict int() invariant in the protocol while making the
      actual call-site honor it. Add a regression test in position-dispatcher.test.ts that
      calls `dispatcher.sendTick(1234.567)` and asserts the payload's monotonic_at_ms is
      1234 (or 1235, whichever rounding is chosen).

    Fix #2: PositionDispatcher must send the wire vx/vy that represents CURRENT input
    intent, NOT the post-step PlayerSim's BNO-instant-set vx=0. Replace the
    `Math.round(local.vx) / Math.round(local.vy)` reads with the same intent-axes source
    as `getIsRunning` already uses:
      `vx: Math.round((this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK)`
      `vy: Math.round((this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK)`
    This will require injecting an axis-callback into PositionDispatcher (parallel to the
    existing `getFacing` and `getIsRunning` callbacks). Mirrors the pattern at
    GameScene.ts:1199-1200 for `onSimulationTickLocal`.

    Fix #3: Remote-render must consume anim_state (the canonical signal) instead of
    re-deriving from vx/vy. Either:
      (a) Decode p.anim_state with unpackAnimState(byte).running and pass it as an
          explicit `isRunning` arg to onSimulationTickRemote (new arg). Renderer uses the
          running flag directly instead of computing from vx/vy hypot.
      (b) Continue to consume vx/vy but ensure they correctly carry intent (after fix #2
          this becomes a non-issue because vx/vy will be non-zero during movement).
      Both are valid. (a) is more principled (single source of truth on the wire);
      (b) is a side-effect of fix #2 and may be sufficient by itself.

  verification: (not applied — diagnose only)
  files_changed: []

  ## Files Involved

  - packages/protocol/src/intents.ts:125-137 — cPositionUpdateSchema with .int() on monotonic_at_ms (root cause #1 location)
  - apps/client/src/prediction/position-dispatcher.ts:30-46 — sendTick passes fractional monotonic_at_ms through (root cause #1 propagation point); reads vx/vy from prediction (root cause #2)
  - apps/client/src/scenes/GameScene.ts:1193 — calls sendTick(_time) with fractional Phaser timestamp (root cause #1 call site)
  - apps/client/src/scenes/GameScene.ts:1213-1226 — remote sim-tick loop reads vx/vy only, ignores anim_state (root cause #3)
  - packages/game-logic/src/step.ts:243-251 — BNO instant-set model returns vx=0/vy=0 always (root cause #2 source)
  - apps/client/src/prediction/predictor.ts:113-114 — predictTick assigns localState from step's output, propagating vx=0/vy=0 (root cause #2 propagation)
  - apps/client/src/__test__/position-dispatcher.test.ts — unit tests use integer time args (1, 2, 3, 1000, 1234), masking root cause #1

  ## Debug Session 2 — edge-collision-off-by-one.md
  ---
  status: diagnosed
  trigger: "Right-edge AND bottom-edge floor collision now stop the local player ONE PIXEL FARTHER away from the wall than pre-06.7 baseline."
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Client-side `isFeetBboxWalkable` walkable-grid path treats the right/bottom corner sample as INCLUSIVE (via `Math.floor`), while the legacy server-side `feetBboxIntersectsAABB` polys path uses STRICT inequality. The two paths disagree by exactly 1 px on the max-axis edges only. Pre-06.7 the server-authoritative snapshot pulled the local sprite back to the polys-path position via reconcile→replay; in 06.7 the self-player reconciler became a no-op so the client's grid-path 1-px-short stop is now what the user sees.
  test: Trace exact stop pixel for a wall at tile boundary on each path.
  expecting: Grid path stops 1 px short of polys path on right + bottom edges only.
  next_action: Return diagnosis (diagnose-only mode).

  ## Symptoms

  expected: Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline
  actual: Sprite stops 1 px farther from wall on right/bottom edges only (left/top unchanged)
  errors: None
  reproduction: Walk right or down into BNCentral floor edge collision; compare to pre-06.7 baseline
  started: Discovered during 2026-05-17 operator UAT for Phase 06.7

  ## Eliminated

  (none — first hypothesis confirmed)

  ## Evidence

  - checked: git log on packages/game-logic/src/step.ts and collision.ts since pre-06.7
    found: Plan 02 (f9b02e3) added an `if (input === undefined) { passthrough }` short-circuit. No collision math change. collision.ts and per-axis loop unchanged since Plan 06.4 (3b696c6).
    implication: Collision arithmetic itself was identical before and after 06.7 — the off-by-one is not a math change. The change in EFFECTIVE final pixel for the local sprite must come from a removed corrective path elsewhere.

  - checked: apps/client/src/prediction/reconciler.ts
    found: `onServerSnapshot(snap, isSelfPlayer = false)` returns immediately when `isSelfPlayer === true` (line 53-54). Added in Plan 03 (8ba0f9b). GameScene calls it with `true` for self-player (GameScene.ts line ~785).
    implication: Pre-06.7 the self-player path went through `applyServerSnapshot` → predictor replaced local x/y with server's authoritative coordinates, then replayed unacked inputs. Sprite was lerp/snapped to the post-replay predicted state, which started from the SERVER's collision-resolved position. Post-06.7 the self-player skips that path entirely; the local sprite stays at whatever pixel the client's own predictor reached.

  - checked: apps/client/src/scenes/GameScene.ts:currentLayoutForSim() and apps/client/src/prediction/predictor.ts:predictTick()
    found: Client predictor passes `walkable_grid: this.roomRenderer?.walkableGrid` into RoomLayout when grid is available. The room is always derived locally → grid is always present in real BNCentral runs.
    implication: Client's predictTick → step() always takes the walkable_grid branch in step.ts:179-225 (per-axis pixel loop calling isFeetBboxWalkable). Server's step() (no walkable_grid) takes the legacy branch (step.ts:226-237) using resolveCollision (feetBboxIntersectsAABB with STRICT inequality).

  - checked: Compare stop-pixel math for the two paths against a tile-aligned wall.
    Setup: walkable cell N (col), unwalkable cell N+1 to the right. tile_w = 44. NAVI_MASK.right = +9.

    Polys path (feetBboxIntersectsAABB, line 63-74 of collision.ts):
      Wall AABB minX = (N+1) * 44.
      Blocked when feetMaxX > minX, i.e. pos.x + 9 > (N+1) * 44, i.e. pos.x > N*44 + 35.
      Stop at pos.x = N*44 + 35 (last clear pixel).

    Grid path (isFeetBboxWalkable, line 159-182 of collision.ts):
      Probe right corner at px = pos.x + 9. col = Math.floor((pos.x + 9) / 44).
      Blocked when col >= N+1, i.e. pos.x + 9 >= (N+1) * 44, i.e. pos.x >= N*44 + 35.
      Per-axis loop in step.ts:187-202 advances probeX one px at a time and breaks
      on the first BLOCKED probe. So at pos.x = N*44 + 34, the next probe is N*44 + 35
      → blocked → break. Final px = N*44 + 34 (one pixel SHORT of polys stop).

    Difference: grid path stops 1 px farther from wall than polys path on the RIGHT edge.

    found: 1-pixel asymmetry on the +x edge.
    implication: Matches user symptom exactly. The asymmetry comes from `Math.floor`
    in isFeetBboxWalkable treating the right-corner pixel sample as inclusive, while
    feetBboxIntersectsAABB treats the bbox right edge as exclusive (`feetMaxX > b.minX`,
    strict). The two boundary conventions are inconsistent.

  - checked: Same comparison on BOTTOM edge. tile_h = 40, NAVI_MASK.bottom = 0.
    Polys: blocked at pos.y > (N+1)*40 → stop at pos.y = (N+1)*40 = N*40 + 40.
    Grid:  feet-bottom px = pos.y + 0. col blocked when pos.y >= (N+1)*40
           → loop breaks at probeY = (N+1)*40 → stop at pos.y = N*40 + 39.
    found: Grid path stops 1 px farther on bottom edge too.
    implication: Matches user symptom — both right AND bottom report +1 px gap.

  - checked: Same comparison on LEFT and TOP edges (symmetry check).
    Left:  NAVI_MASK.left = -9.
      Polys: blocked at pos.x < K*44 + 9 → stop at pos.x = K*44 + 9.
      Grid:  feet-left px = pos.x - 9. floor((pos.x-9)/44) must remain >= K.
             At pos.x = K*44 + 9 → floor(K*44/44) = K → walkable. At pos.x = K*44 + 8
             → floor((K*44 - 1)/44) = K-1 → blocked. Stop at pos.x = K*44 + 9.
    Top:   NAVI_MASK.top = -9. Mirror of left math — both stop at K*40 + 9.
    found: Left and top edges produce IDENTICAL stop pixels on both paths.
    implication: Explains why operator only reported right+bottom drift. Confirms
    this is not a uniform offset, it is the specific Math.floor-vs-strict-inequality
    asymmetry on the MAX edges.

  - checked: When did the asymmetry first exist? grep history of isFeetBboxWalkable.
    found: Introduced in 0c40432 (Plan 06.1-02, WALK→RUN rename + walkable-grid
    per-axis collision). The 1-px-short behavior has been present in client-side
    prediction since 06.1 but was previously masked by self-player reconciliation
    pulling the sprite to the server's polys-path position.
    implication: This is a latent bug exposed by the client-trust shift, not a new
    bug introduced by Plan 02 or 03's math. The change in OBSERVED behavior is real;
    the underlying arithmetic discrepancy predates 06.7.

  ## Resolution

  root_cause: Boundary-convention mismatch between the client-side walkable-grid collision path and the server-side polys collision path. `packages/game-logic/src/collision.ts:172` uses `Math.floor(px / grid.tile_w)` to sample the right/bottom corner of the feet AABB, treating that corner pixel as INSIDE the bbox. `packages/game-logic/src/collision.ts:69-72` (`feetBboxIntersectsAABB`) treats the bbox using STRICT inequality (`feetMaxX > b.minX`, `feetMaxY > b.minY`), where the corner pixel is OUTSIDE. The two paths produce stops that differ by exactly 1 px on the MAX axes (right, bottom) and agree on the MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path was a corrective sink — `predictor.applyServerSnapshot` replaced the client's predicted position with the server's polys-path-resolved position, then replayed unacked inputs forward — so the user saw the polys-path (flush) stop. Phase 06.7 Plan 03 changed `ReconcileEngine.onServerSnapshot` to early-return for the self-player (`apps/client/src/prediction/reconciler.ts:53-54`), removing the corrective sink. The client now displays the grid-path stop directly, which is 1 px short of pre-06.7 baseline on right and bottom edges only.

  fix: (diagnose-only — not applied)

  verification: (diagnose-only — not applied)

  files_changed: []

  ## Debug Session 3 — v3-rejection-wrong-close-code.md (false positive — context for Plan 07)
  ---
  status: diagnosed
  trigger: "Automated integration test protocol-v4-handshake.integ.test.ts v3-rejection assertion AssertionError: expected 524 to be 4400 (Phase 06.7 UAT Test 5, 2026-05-17)"
  created: 2026-05-17T03:28:00Z
  updated: 2026-05-17T03:35:00Z
  ---

  ## Current Focus

  hypothesis: Reported failure is NOT reproducible at HEAD (commit 9816275). Implementation already throws ServerError(4400) and the SDK surfaces 4400. The UAT capture is stale relative to the current codebase, OR was captured under conditions not reproduced by `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`.
  test: Run the test in isolation (5x) and as part of partial parallel suite — all 8 runs show `v3 client receives close code 4400 with PROTOCOL_VERSION_MISMATCH` PASS.
  expecting: confirm no behavioral change required; UAT entry should be marked stale or reproduction conditions clarified
  next_action: report ROOT CAUSE FOUND — no code change needed; recommend re-running the failed UAT test on the current HEAD to confirm before scheduling any fix work

  ## Symptoms

  expected: `expect(e.code).toBe(4400)` and `expect(String(e.message ?? '')).toMatch(/PROTOCOL_VERSION_MISMATCH/)` both pass for a v3 client; v3 is rejected before Better-Auth.
  actual (per UAT): AssertionError: expected 524 to be 4400 at protocol-v4-handshake.integ.test.ts:43:22 (v3 IS rejected; code is reported as 524 instead of 4400).
  actual (this debug session, HEAD 9816275): test passes — `e.code === 4400`, message matches `/PROTOCOL_VERSION_MISMATCH/`. Verified across 5 isolated runs + 1 partial parallel run (alongside auth/reconnect/boot/sigterm tests).
  errors: AssertionError: expected 524 to be 4400
  reproduction (claimed): `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`
  started: discovered 2026-05-17 during Phase 06.7 UAT Test 5

  ## Eliminated

  - hypothesis: Server `onAuth` throws something other than `ServerError(4400, ...)` for protocol-version mismatch
    evidence: `apps/server/src/RebnoRoom.ts:394-398` explicitly does `throw new ServerError(4400, \`PROTOCOL_VERSION_MISMATCH expected=${v.expected} got=${v.got}\`)` when `validateAuthFrame` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH' }`. `validateAuthFrame` in `packages/protocol/src/intents.ts:350-364` returns that error iff `parsed.data.protocol_version !== PROTOCOL_VERSION` (currently 4).
    timestamp: 2026-05-17T03:30:00Z

  - hypothesis: Colyseus matchmaker intercepts the onAuth-thrown ServerError(4400) and rewrites the code to 524 (MATCHMAKE_EXPIRED)
    evidence: Traced the full server-side error path. `@colyseus/core` Room.cjs:760-789 catches the onAuth exception, deletes the reservation, and re-throws. Only adds a code if missing (`if (!e.code) e.code = APPLICATION_ERROR`) — 4400 is preserved. `@colyseus/ws-transport` WebSocketTransport.cjs:175-177 catches that and calls `client.error(e.code, e.message, ...)` (sends the in-band ERROR frame with code=4400 + msg), then closes the WS with `CloseCode.WITH_ERROR` (4002, not 524). The matchmaker code 524 (`ErrorCode.MATCHMAKE_EXPIRED`) is only thrown from `MatchMaker.cjs` reconnection-token / consumed-seat paths (lines 215-220, 715-720, 748), none of which apply to a fresh joinOrCreate with a stale protocol version.
    timestamp: 2026-05-17T03:31:00Z

  - hypothesis: SDK rewrites code 4400 → 524 during MatchMakeError construction
    evidence: `@colyseus/sdk/dist/colyseus.js:9641-9660` `consumeSeatReservation` registers `room.onError.once((code, message) => reject(new ServerError(code, message)))` and then `createMatchMakeRequest:9716-9722` re-throws as `new MatchMakeError(error.message, error.code)` — code is passed through unchanged. The SDK's WS message handler `colyseus.js:8878-8881` dispatches `Protocol.ERROR` frames as `onError.invoke(code, message)` with the server-sent code intact.
    timestamp: 2026-05-17T03:32:00Z

  - hypothesis: Test is flaky and only sometimes returns 524 instead of 4400
    evidence: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times back-to-back at HEAD 9816275: 3/3 tests passed in every run. Ran a partial parallel suite (protocol-v4 + auth + reconnect + boot + sigterm) — protocol-v4 v3-rejection still PASSED with `e.code === 4400` (252 ms). Concurrent test load did not surface the 524 result.
    timestamp: 2026-05-17T03:34:00Z

  ## Evidence

  - timestamp: 2026-05-17T03:28:00Z
    checked: `git rev-parse HEAD` after `git reset --hard 98162752325bb4bd5cd8d64694b4f175a5fef5fa`
    found: HEAD is `9816275 test(06.7): complete UAT - 3 passed, 2 issues, 1 skipped, 6 gaps logged` — the same commit on which the UAT failure was recorded.
    implication: We're inspecting the exact tree the UAT ran against.

  - timestamp: 2026-05-17T03:29:00Z
    checked: `apps/server/src/RebnoRoom.ts:373-401` (RebnoRoom.onAuth)
    found: Step-1 protocol validation runs FIRST and throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")` (or `c2s.auth PARSE_ERROR` for malformed frames). Step 2 dev-bypass + Step 3 Better-Auth getSession are both gated behind a successful validateAuthFrame.
    implication: Source matches the documented D-03/D-12 contract. No code defect on the server side.

  - timestamp: 2026-05-17T03:29:30Z
    checked: `packages/protocol/src/intents.ts:343-364` (`validateAuthFrame`) + `packages/protocol/src/version.ts:27` (PROTOCOL_VERSION)
    found: `validateAuthFrame({type:'auth', protocol_version: 3, session_token: 'dev-bypass'})` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH', expected: 4, got: 3 }` — matching the test input.
    implication: The validator distinguishes PARSE_ERROR from PROTOCOL_VERSION_MISMATCH correctly; the `if (v.error === 'PROTOCOL_VERSION_MISMATCH')` branch will fire as intended.

  - timestamp: 2026-05-17T03:30:00Z
    checked: Colyseus 0.17 shared-types `ErrorCode` enum at `node_modules/.pnpm/@colyseus+shared-types@0.17.6/.../Protocol.d.ts`
    found: `MATCHMAKE_EXPIRED: 524` is an HTTP matchmaking error code (reconnection-token / seat-consumption races), not a WebSocket close code. WS close codes start at 1000 (`CloseCode.NORMAL_CLOSURE`) with the 4000-range reserved for Colyseus-app (CONSENTED=4000, SERVER_SHUTDOWN=4001, WITH_ERROR=4002, FAILED_TO_RECONNECT=4003, MAY_TRY_RECONNECT=4010).
    implication: A genuine 524 result would mean MatchMaker rejected the seat reservation BEFORE WS upgrade (HTTP-side reconnect or stale-seat path), not that onAuth's 4400 was rewritten.

  - timestamp: 2026-05-17T03:30:30Z
    checked: `node_modules/.pnpm/@colyseus+ws-transport@0.17.../WebSocketTransport.cjs:148-179` (onConnection error handler)
    found: When `connectClientToRoom` throws, transport calls `client.error(e.code, e.message, ...)` to send the in-band ERROR frame (preserving the server-thrown code, here 4400), then closes the WS with `CloseCode.WITH_ERROR` (4002 — not 524). The SDK reads the in-band ERROR frame before the close fires and surfaces it as `ServerError(4400, "PROTOCOL_VERSION_MISMATCH...")`.
    implication: 524 cannot reach the client from the onAuth path — it would require an HTTP-side MatchMaker exception or a reconnection-token race.

  - timestamp: 2026-05-17T03:33:00Z
    checked: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times at HEAD 9816275.
    found: All 5 runs report "Test Files 1 passed (1), Tests 3 passed (3)". Specifically the v3-rejection assertion (line 43 `expect(e.code).toBe(4400)`) passes — `e.code` IS 4400. Server logs show `validateAuthFrame` rejection path firing and the WS close happening before `D-51c onjoin_start` ever logs (auth runs BEFORE onJoin, so no onJoin telemetry for the v3 client — correct).
    implication: The reported AssertionError does not reproduce on the current HEAD. The implementation at HEAD already satisfies the test's expected contract.

  - timestamp: 2026-05-17T03:34:00Z
    checked: Partial parallel suite `protocol-v4-handshake + auth + reconnect + boot + sigterm`.
    found: protocol-v4-handshake v3-rejection still passes (`✓ ... v3 client receives close code 4400 ... 252ms`) under concurrent test load. Other failures in that run (sigterm, boot) are unrelated DB-seed errors — not in scope here.
    implication: The 524 result is not surfaced by adjacent-test concurrency either; the UAT capture is stale or was taken against a different (probably earlier) tree.

  - timestamp: 2026-05-17T03:35:00Z
    checked: `git log --oneline 1ba1fe6..9816275 -- apps/server/test/protocol-v4-handshake.integ.test.ts apps/server/src/RebnoRoom.ts packages/protocol/src/*` — Phase 06.7 commits touching this test or its dependencies.
    found: Only commit `f9b02e3 feat(06.7-02): accept client position updates` touched these files between Plan 01 (`1ba1fe6`) and HEAD (`9816275`). That commit RENAMED `protocol-v3-handshake.integ.test.ts → protocol-v4-handshake.integ.test.ts` and bumped the assertion from v2/v3 to v3/v4 — but did NOT change the assertion that `e.code === 4400`, nor the server-side ServerError(4400) call.
    implication: There is no obvious window in which the implementation diverged from the test. The most likely UAT capture conditions are (a) the test ran against an unbuilt/stale `@rebno/protocol` dist where `PROTOCOL_VERSION` was still 3 — causing the v3 frame to PASS validation, then later fail at the seat-reservation stage with 524; or (b) the test was run from a different worktree that was several commits behind HEAD.

  ## Resolution

  root_cause: Reported failure is not reproducible at HEAD 9816275. The server-side implementation in `apps/server/src/RebnoRoom.ts:394-398` correctly throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")`, the Colyseus WS transport forwards the code 4400 via an in-band ERROR frame, and the SDK surfaces it to the test as `MatchMakeError{ code: 4400, message: "PROTOCOL_VERSION_MISMATCH expected=4 got=3" }`. All 6 test runs (5 isolated + 1 partial-parallel) PASS the `expect(e.code).toBe(4400)` assertion.

  Most likely explanation for the UAT-recorded 524 result: the UAT was executed against a stale `packages/protocol` build artifact (TypeScript dist or cached test transform) where `PROTOCOL_VERSION` was still 3. With version 3 on BOTH server and (test-loaded) `@rebno/protocol`, the v3 frame `validateAuthFrame` passes, the dev-bypass branch in onAuth proceeds, onJoin runs — and a subsequent step in the joinOrCreate → consumeSeatReservation flow rejects with `ErrorCode.MATCHMAKE_EXPIRED` (524). This is consistent with the SUMMARY note that the v4-acceptance case also fails with "seat reservation expired" — the same 524 surface, same Colyseus-matchmaking infra issue that Plan 02 SUMMARY already flagged for `test/reconnect.integ.test.ts`.

  Secondary candidate (lower-probability): the test runner in the UAT was sharing a `node_modules` cache with a worktree at a lower commit where the assertion order was different. The renamed test file (`protocol-v3-handshake.integ.test.ts` → `protocol-v4-handshake.integ.test.ts`) could have caused the runner to load a stale prebuilt copy that asserted against a different code.

  fix: No code change required. Recommended actions for the UAT loop:
    1. From a clean working tree at `9816275`, run `pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` and confirm the 3 tests pass.
    2. If the failure DOES reproduce, capture the actual SDK error object via `console.log({ code: e.code, message: e.message, name: e.name, raw: e })` inside the test's `catch (err)` block on a one-off branch and re-run. The presence of `name: 'MatchMakeError'` + `code: 524` + a message that does NOT contain `PROTOCOL_VERSION_MISMATCH` would confirm the stale-protocol-build hypothesis. The presence of `code: 524` + a message that DOES contain `PROTOCOL_VERSION_MISMATCH` would indicate a new SDK code-rewrite path that did not appear in this investigation.
    3. The UAT entry `06.7-UAT.md:105-114` ("severity: major") should be downgraded to `status: not_reproducible` pending step 1.

  verification: Run `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` → 3 tests pass, including v3-rejection asserting `e.code === 4400`. Verified 6× at HEAD `9816275` in this session. The implementation in `apps/server/src/RebnoRoom.ts:373-401` is correct against the documented D-03/D-12 contract; no source change is required.

  files_changed: []

  ## Plan 05 (two-player remote sync — 3 root causes)
  ---
  phase: 06.7
  plan: 05
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/render/PlayerRenderer.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
    - REQ-SRV-03
  tags: [client, prediction, dispatch, gap-closure, remote-sync]
  must_haves:
    truths:
      - "Every position_update emitted by PositionDispatcher carries an INTEGER monotonic_at_ms — fractional Phaser rAF timestamps never reach the wire, so server zod parse never rejects the frame on the .int() constraint"
      - "Every position_update emitted while the user holds a movement key carries vx ≠ 0 and/or vy ≠ 0 (matching the input-intent axis vector × RUN_SPEED_PX_PER_TICK), NOT the post-step BNO-instant-set vx=0/vy=0"
      - "Every position_update emitted while the user holds NO movement key carries vx = 0 and vy = 0"
      - "Remote-player rendering plays the running animation when the broadcast PlayerState.anim_state.running bit is set, regardless of broadcast vx/vy values (single source of truth on the wire)"
      - "After the three fixes, the 2nd joiner sees the 1st joiner's sprite move, animate, and update at the full sim-tick cadence; the 1st joiner sees the 2nd joiner's sprite update smoothly without dropped frames"
      - "All pre-existing PositionDispatcher unit tests continue to pass (no regression on payload shape, seq monotonicity, setRoom routing, idle cadence, anim_state authoring)"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher with Math.floor(monotonic_at_ms), axis-vector callback, and vx/vy authored from input intent (not post-step PlayerSim)"
        contains: "Math.floor(monotonic_at_ms)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Regression tests for RC #1 (fractional monotonic_at_ms floor) and RC #2 (vx/vy authored from axis vector callback)"
        contains: "1234.567"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "Wires axis-vector callback into PositionDispatcher constructor; remote sim-tick loop passes unpackAnimState(p.anim_state).running into onSimulationTickRemote"
        contains: "unpackAnimState"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        provides: "onSimulationTickRemote consumes an explicit isRunning flag (from broadcast anim_state) instead of deriving it from vx/vy hypot"
        contains: "isRunning"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts:1193 (sendTick call site)"
        to: "apps/client/src/prediction/position-dispatcher.ts:sendTick monotonic_at_ms payload field"
        via: "Math.floor coercion at the dispatcher boundary"
        pattern: "Math\\.floor\\(monotonic_at_ms\\)"
      - from: "apps/client/src/scenes/GameScene.ts:676-686 (dispatcher construction)"
        to: "apps/client/src/prediction/position-dispatcher.ts sendTick vx/vy fields"
        via: "new axis-vector callback (parallel to existing getFacing / getIsRunning)"
        pattern: "getAxisVector"
      - from: "apps/client/src/scenes/GameScene.ts:1213-1226 (remote sim-tick loop)"
        to: "apps/client/src/render/PlayerRenderer.ts:onSimulationTickRemote"
        via: "new isRunning argument decoded from unpackAnimState(p.anim_state).running"
        pattern: "unpackAnimState\\(p\\.anim_state\\)"
  ---

  <objective>
  Restore the REQ-CLI-08 two-player smoke milestone: each peer sees the other's
  position, velocity, facing, and running animation update in near-real-time.

  Closes three layered root causes diagnosed in
  `.planning/debug/two-player-remote-sync-broken.md`:
    RC #1 — fractional monotonic_at_ms fails the wire schema's .int() constraint,
            dropping ~100% of position_update frames at the server boundary.
    RC #2 — PositionDispatcher reads vx/vy from PredictionEngine.getLocalState(),
            which always returns vx=0/vy=0 per the BNO instant-set model.
            Every wire frame carries vx=0, vy=0; server echoes 0; remote
            renderer derives Stand-frame from hypot(0,0)=0.
    RC #3 — Remote-render loop reads vx/vy only and ignores broadcast
            anim_state. Even when RC #2 is fixed, this is the principled
            single-source-of-truth path on the wire.

  Purpose: REQ-CLI-08 HARD MILESTONE regression. Pre-06.7 worked. Phase 06.7
  client-trust flip introduced these three latent defects.

  Output: Three atomic commits restoring two-player sync. Operator UAT Test 3
  (two-player smoke), Test 4 (was-Test-3 incidental edge collision passes
  separately via Plan 06), and Test 6 (idle cadence) all PASS post-fix.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/two-player-remote-sync-broken.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md
  @apps/client/src/prediction/position-dispatcher.ts
  @apps/client/src/__test__/position-dispatcher.test.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/render/PlayerRenderer.ts
  @packages/protocol/src/intents.ts
  @packages/protocol/src/anim-state.ts
  @packages/game-logic/src/step.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/protocol/src/intents.ts (cPositionUpdateSchema, lines 125-137):
  ```
  monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)
  vx, vy:          z.number().int().min(-32768).max(32767)
  anim_state:      z.number().int().min(0).max(255)
  ```

  From packages/protocol/src/anim-state.ts:
  ```
  export function unpackAnimState(byte: number): { facing: Direction; running: boolean }
  export function packAnimState(facing: Direction, isRunning: boolean): number
  export type Direction = 'D'|'DR'|'R'|'UR'|'U'|'UL'|'L'|'DL'
  ```

  From packages/game-logic/src/constants.ts:
  ```
  export const RUN_SPEED_PX_PER_TICK: number = 5;
  ```

  From apps/client/src/prediction/position-dispatcher.ts (current constructor):
  ```
  constructor(
    room: Pick<Room, 'send'>,
    prediction: Pick<PredictionEngine, 'getLocalState'>,
    getFacing: () => Direction,
    getIsRunning: () => boolean,
  )
  ```

  From apps/client/src/scenes/GameScene.ts:64:
  ```
  import { RUN_SPEED_PX_PER_TICK, type Direction } from '@rebno/game-logic';
  ```

  From apps/client/src/scenes/GameScene.ts:676-686 (current dispatcher construction
  — note the inline axis-vector computation that already exists for getIsRunning;
  the new axis-vector callback should use the same source):
  ```
  this.positionDispatcher = new PositionDispatcher(
    this.room,
    this.prediction,
    () => this.playerRenderer?.getLocalFacing() ?? 'D',
    () => {
      const vx = (this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK;
      const vy = (this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK;
      return Math.hypot(vx, vy) > 0;
    },
  );
  ```

  From apps/client/src/scenes/GameScene.ts:1218-1224 (current remote sim-tick loop):
  ```
  this.playerRenderer.onSimulationTickRemote(
    sessionId,
    vx,
    vy,
    p.x ?? 0,
    p.y ?? 0,
  );
  ```
  (PlayerSnapshotShape.anim_state is already declared optional at line 122.)

  From apps/client/src/render/PlayerRenderer.ts:423-470 (current
  onSimulationTickRemote derives isRunning via hypot(renderVx, renderVy) inside
  deriveFrameWithAuthoritativeFacing).
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1 (RC #1): Floor monotonic_at_ms at the dispatcher boundary + regression test</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts</files>
    <behavior>
      - RED: Add a regression test `it('RC #1 — floors fractional monotonic_at_ms to satisfy wire .int() contract', ...)` that calls `dispatcher.sendTick(1234.567)` and asserts the captured payload's `monotonic_at_ms === 1234` AND `cPositionUpdateSchema.safeParse(payload).success === true`. Run vitest — MUST fail because the current dispatcher passes 1234.567 through unmodified and zod rejects it.
      - GREEN: Change `apps/client/src/prediction/position-dispatcher.ts` line 42 from `monotonic_at_ms,` to `monotonic_at_ms: Math.floor(monotonic_at_ms),`. Update the surrounding comment to cite RC #1 from `.planning/debug/two-player-remote-sync-broken.md` and explain that Phaser's `_time` is a fractional rAF/performance.now timestamp while the wire schema requires `.int()`. Carry the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` traceable-reqs tags.
      - Re-run vitest — MUST pass.
      - Verify the existing tests (`sends a valid position_update payload with rounded local state`, `increments seq monotonically`, `setRoom routes subsequent ticks`, `uses the injected facing and running callbacks`, `D-06 idle cadence`) all still pass with their integer time args.
    </behavior>
    <action>
      Apply RC #1 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #1 option (a) — Math.floor coercion at the dispatcher boundary. Do NOT loosen the schema in `packages/protocol/src/intents.ts` (option (b) was rejected — preserves the u32 binary contract per Plan 01 codex review concern #7 fold).

      Test placement: append new `it(...)` inside the existing `describe('PositionDispatcher', ...)` block in `apps/client/src/__test__/position-dispatcher.test.ts`. Use the existing `makeMockRoom` / `makeMockPrediction` helpers and `cPositionUpdateSchema` import (already present at top of file). Tag the test with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]` to match the file's convention.

      Commit message: `fix(06.7): floor monotonic_at_ms at dispatcher boundary (RC #1)`. Body must reference `.planning/debug/two-player-remote-sync-broken.md` and explain that fractional Phaser timestamps were failing the wire schema's `.int()` constraint.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass including the new fractional-timestamp regression test. Dispatcher source contains `Math.floor(monotonic_at_ms)` at line 42 (or equivalent) with the RC #1 comment and traceable-reqs tags intact.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (RC #2): Author vx/vy from input-intent axis vector via injected callback</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Add two regression tests inside `describe('PositionDispatcher', ...)`:
          (a) `it('RC #2 — authors vx/vy from injected axis-vector callback during movement', ...)` — construct the dispatcher with a `getAxisVector` callback that returns `{ axisX: 1, axisY: -1 }`. Mock prediction returns `{ x: 100, y: 200, vx: 0, vy: 0 }` (post-step BNO instant-set). Call `sendTick(1000)`. Assert the captured payload's `vx === Math.round(1 * RUN_SPEED_PX_PER_TICK)` (= 5) and `vy === Math.round(-1 * RUN_SPEED_PX_PER_TICK)` (= -5). Assert `cPositionUpdateSchema.safeParse(payload).success === true`.
          (b) `it('RC #2 — authors vx/vy as 0 when no axis is held', ...)` — callback returns `{ axisX: 0, axisY: 0 }`. Assert payload `vx === 0` and `vy === 0`.
        Both tests MUST fail initially because the current dispatcher reads `local.vx / local.vy` (always 0).
      - GREEN: Modify `apps/client/src/prediction/position-dispatcher.ts`:
          * Add a 5th constructor parameter `getAxisVector: () => { axisX: -1 | 0 | 1; axisY: -1 | 0 | 1 }` (parallel to existing `getFacing` / `getIsRunning`).
          * Import `RUN_SPEED_PX_PER_TICK` from `@rebno/game-logic` at the top of the file.
          * Replace lines 37-38 (`vx: Math.round(local.vx), vy: Math.round(local.vy),`) with:
              `const axes = this.getAxisVector();`
              `vx: Math.round(axes.axisX * RUN_SPEED_PX_PER_TICK),`
              `vy: Math.round(axes.axisY * RUN_SPEED_PX_PER_TICK),`
          * Drop the unused `prediction` parameter ONLY IF no other call site reads `local.x` / `local.y` — currently lines 35-36 still consume `local.x` and `local.y`, so keep `prediction` and only swap the velocity reads.
          * Update the header comment to cite RC #2 and explain that vx/vy on the wire MUST represent current input INTENT (per-tick displacement vector × RUN_SPEED_PX_PER_TICK), NOT the post-step PlayerSim's BNO-instant-set vx=0. Mirror the GameScene.ts:1199-1200 pattern that already does this for `onSimulationTickLocal`.
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:676-686`:
          * Add a 5th argument to the `new PositionDispatcher(...)` call: `() => ({ axisX: (this.inputDispatcher?.axisX() ?? 0) as -1|0|1, axisY: (this.inputDispatcher?.axisY() ?? 0) as -1|0|1 })`.
          * The existing 4th callback `getIsRunning` can be left as-is OR simplified to consume the same axis source — leave as-is to minimize blast radius.
      - Update existing PositionDispatcher tests that construct the dispatcher with the old 4-arg signature: add a 5th `() => ({ axisX: 0, axisY: 0 })` arg (idle axis vector — preserves existing assertions about vx/vy from `state = { vx: 1.1, vy: -2.4 }` becoming vx/vy=0 on the wire after RC #2 — UPDATE those assertions accordingly: the old test `sends a valid position_update payload with rounded local state` asserted `vx: 1, vy: -2`; after RC #2 those become `vx: 0, vy: 0` because the wire vx/vy now ignores prediction state and reads from the axis callback). Re-state the test's intent in its description to reflect the new contract.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #2 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #2. Inject an axis-vector callback into PositionDispatcher parallel to `getFacing` / `getIsRunning`. Compute vx/vy from the axis vector × RUN_SPEED_PX_PER_TICK (mirroring GameScene.ts:1199-1200 used for `onSimulationTickLocal`). Do NOT modify `packages/game-logic/src/step.ts` — the BNO instant-set vx=0/vy=0 contract is correct for the simulation; this fix moves the wire-side velocity authoring to the dispatcher.

      Constructor signature change is a callsite update — only one production callsite (GameScene.ts:676-686) and four test constructions (apps/client/src/__test__/position-dispatcher.test.ts). All five must be updated.

      Update existing test assertions that pinned vx=1/vy=-2 on the wire — those assertions are now obsolete (wire vx/vy is sourced from axes, not prediction). New contract: wire vx/vy = round(axisX × 5), round(axisY × 5).

      Tag the new tests `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag the modified dispatcher code with the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` markers.

      Commit message: `fix(06.7): author wire vx/vy from input-intent axis vector (RC #2)`. Body explains BNO instant-set returns vx=0/vy=0 from step() so the dispatcher must derive wire velocity from the input axes, mirroring the local-renderer pattern at GameScene.ts:1199-1200.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass (existing + 2 new RC #2 tests + 1 new RC #1 test from Task 1). Dispatcher constructor takes 5 args including the new axis-vector callback. Wire vx/vy carries `round(axis × RUN_SPEED_PX_PER_TICK)`. GameScene constructs the dispatcher with the new callback wired to `inputDispatcher.axisX()` / `axisY()`. `pnpm -C apps/client exec tsc --noEmit` is green.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3 (RC #3): Remote-render consumes broadcast anim_state.running instead of deriving from vx/vy</name>
    <files>apps/client/src/render/PlayerRenderer.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Locate or create a PlayerRenderer.onSimulationTickRemote unit test in `apps/client/src/__test__/`. If `player-renderer.test.ts` (or similar) exists, add a test `it('RC #3 — consumes explicit isRunning flag instead of deriving from vx/vy hypot', ...)` that:
          * Sets up a remote with a known frame.
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, /* isRunning */ true)` and asserts the rendered frame key matches a Run-* sprite (e.g. starts with a Run prefix per `deriveFrameWithAuthoritativeFacing` contract).
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, false)` and asserts a Stand-* frame.
        If no test file exists, create `apps/client/src/__test__/player-renderer-remote-anim.test.ts` with minimal Phaser scene mocking sufficient to exercise the frame-key derivation path. Use the existing `firstRemoteFrame` window hook (`__rebno.firstRemoteFrame` in PlayerRenderer.ts:485) as the observable signal.
        Test MUST fail before the GREEN change because the renderer currently ignores any explicit isRunning arg.
      - GREEN: Modify `apps/client/src/render/PlayerRenderer.ts:423-470 onSimulationTickRemote`:
          * Add a new optional 6th parameter `isRunning?: boolean`.
          * When `isRunning` is provided, override the running-flag derivation: pass an explicit running override into `deriveFrameWithAuthoritativeFacing`. Two implementation options:
              (a) Add an `isRunningOverride?: boolean` arg to `deriveFrameWithAuthoritativeFacing` and short-circuit its internal hypot check when defined. Cleaner.
              (b) Pre-compute the running flag locally and call a variant. Smaller blast radius if (a) requires touching multiple deriveFrame call paths.
            Prefer (a). If `deriveFrameWithAuthoritativeFacing` lives in `apps/client/src/render/SpriteStateMachine.ts` and has unit tests, add an optional `isRunningOverride?: boolean` parameter that defaults to undefined (preserves existing behavior for callers that don't pass it).
          * Document the change with a comment citing RC #3 from `.planning/debug/two-player-remote-sync-broken.md` and Hard Rule 1 carve-out (movement = client-authoritative; anim_state byte is the single source of truth on the wire).
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:1213-1226 remote sim-tick loop`:
          * Import `unpackAnimState` from `@rebno/protocol` (add to existing import line).
          * Inside the for loop, after reading `const vx = p.vx ?? 0; const vy = p.vy ?? 0;`, add:
              `const animByte = p.anim_state ?? 0;`
              `const isRunning = unpackAnimState(animByte).running;`
          * Pass `isRunning` as a 6th argument to `playerRenderer.onSimulationTickRemote(sessionId, vx, vy, p.x ?? 0, p.y ?? 0, isRunning)`.
          * Update the surrounding comment to cite RC #3 — broadcast anim_state.running is the canonical signal; vx/vy are kept as positional/animation inputs but the running flag now flows from the explicit byte.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #3 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #3 option (a) — the principled single-source-of-truth path. Even though RC #2 alone (Task 2) would propagate non-zero vx/vy and incidentally fix the animation via the hypot derivation, RC #3 makes the anim_state byte the canonical wire signal and decouples animation correctness from velocity correctness. This survives future changes where vx/vy might be quantized, clamped, or zeroed for anti-cheat.

      If `deriveFrameWithAuthoritativeFacing` is exported from `apps/client/src/render/SpriteStateMachine.ts`, add the optional `isRunningOverride?: boolean` arg there and update existing call sites in PlayerRenderer.ts to pass `undefined` (preserves behavior). The new remote path passes the unpacked anim_state.running flag.

      PlayerSnapshotShape at GameScene.ts:122 already declares `anim_state?: number` — no shape change needed.

      Tag any new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag modified renderer code with `[impl->REQ-CLI-04] [impl->REQ-CLI-08]`.

      Commit message: `fix(06.7): remote-render consumes broadcast anim_state.running (RC #3)`. Body explains that the broadcast anim_state byte is the canonical wire signal per Hard Rule 1's movement carve-out; previously the remote renderer ignored it and re-derived from vx/vy hypot, masking RC #2.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run</automated>
    </verify>
    <done>
      All apps/client vitest tests pass (including any new RC #3 player-renderer test). `pnpm -C apps/client exec tsc --noEmit` is green. GameScene.ts remote-sim-tick loop calls `playerRenderer.onSimulationTickRemote(sid, vx, vy, x, y, isRunning)` with `isRunning` decoded via `unpackAnimState(p.anim_state ?? 0).running`. PlayerRenderer.onSimulationTickRemote honors the explicit isRunning override when provided.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (position_update wire path) | client-authored payload; server zod-validates; carve-out: server stores and broadcasts without semantic validation per Hard Rule 1's narrow movement carve-out (anti-cheat deferred to Phase 06.8) |
  | server → client (state-sync of broadcast PlayerState) | server-authoritative broadcast of last-known position/velocity/facing/anim_state; client renderer trusts these values for remote players |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-05-01 | Tampering | wire monotonic_at_ms (after Math.floor coercion) | accept | The .int() schema constraint remains enforced server-side. Math.floor at the client dispatcher is a correctness fix for honest clients; a malicious client could still craft any integer value. Bound by .min(0).max(0xffffffff) already in the schema. |
  | T-06.7-05-02 | Tampering | wire vx/vy (now sourced from injected axis-vector callback) | accept | Wire vx/vy are still client-authored — narrow movement carve-out (Hard Rule 1) accepts this. Server stores and broadcasts. Anti-cheat (illegal-position fall trigger, speed cap, walkable-grid sanity) is deferred to Phase 06.8 per Plan 04 deferral note. |
  | T-06.7-05-03 | Tampering | broadcast anim_state.running consumed by remote renderer | accept | A malicious peer could broadcast running=true while sending vx=0/vy=0 (visual desync only — no gameplay-state effect). The anim_state byte is authored by the broadcasting client and trusted by other clients for rendering. Deferred to 06.8 anti-cheat. |
  | T-06.7-05-04 | Information Disclosure | injected axis-vector callback exposing inputDispatcher state | accept | The callback returns axisX/axisY (-1/0/1) which is already implicit in the existing wire payload (deducible from vx/vy in the OLD-Phase-pre-06.7 derivation pattern at GameScene.ts:1199-1200). No new disclosure surface. |
  </threat_model>

  <verification>
    - All three RCs fixed in three atomic commits.
    - `pnpm -C apps/client exec vitest run` — green (existing 247 tests + 4 new regression tests).
    - `pnpm -C apps/client exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (no schema change but executor MUST run this to verify nothing in @rebno/protocol export surface broke; lesson from Test 5 stale-protocol-build false positive — note recorded by Plan 07).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: re-run Test 4 (two-player smoke) — 2nd joiner sees 1st joiner's position updates, velocity-driven movement, and running animation. 1st joiner sees same of 2nd. Both should PASS.
    - Operator HUMAN-UAT Test 6 (idle cadence ~150 frames over 5s) re-checkable post-fix since RC #1 was blocking that observation.
  </verification>

  <success_criteria>
  - Three commits land on the main branch (or 06.7 branch per execute-phase worktree convention), each with a clear `fix(06.7): ... (RC #N)` message.
  - The 4 new regression tests (1 for RC #1, 2 for RC #2, 1 for RC #3) all pass.
  - All pre-existing PositionDispatcher and PlayerRenderer tests still pass (with updated assertions where the RC #2 contract change made old assertions obsolete).
  - Operator re-runs UAT Test 4 two-player smoke and reports `pass`.
  - UAT.md gap entries for the three blocker truths flip from `status: failed` to `status: resolved` (or new UAT pass log entry); STATE.md and ROADMAP.md update is the responsibility of `/gsd-verify-work 06.7` after the operator signs off.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-05-SUMMARY.md` documenting:
  - The three RCs and which commit fixed each.
  - The new constructor signature for PositionDispatcher (5 args).
  - The new optional 6th arg `isRunning` for `onSimulationTickRemote` and the underlying `isRunningOverride` on `deriveFrameWithAuthoritativeFacing`.
  - All regression tests added (file + describe name + it name).
  - The four pre-existing test assertions that needed updating to reflect the RC #2 contract change.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/two-player-remote-sync-broken.md` and the three UAT gap entries.
  </output>

  ## Plan 06 (edge collision off-by-1px)
  ---
  phase: 06.7
  plan: 06
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/game-logic/src/collision.ts
    - packages/game-logic/test/collision.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  tags: [game-logic, collision, gap-closure, edge-off-by-one]
  must_haves:
    truths:
      - "After the fix, the walkable-grid path (isFeetBboxWalkable, consumed by client-side prediction in step.ts:179-225) stops the player AT THE SAME PIXEL as the legacy polys path (feetBboxIntersectsAABB, consumed by server-side step.ts:226-237) for every wall orientation: right, bottom, left, top"
      - "Right-edge tile-boundary wall stop matches pre-06.7 baseline: pos.x = N*44 + 35 (where N is the last walkable column), NOT N*44 + 34"
      - "Bottom-edge tile-boundary wall stop matches pre-06.7 baseline: pos.y = N*40 + 40 (where N is the last walkable row), NOT N*40 + 39"
      - "Left-edge and top-edge stops remain unchanged at K*44 + 9 and K*40 + 9 respectively (no regression on the agreeing axes)"
      - "Pre-existing collision.test.ts and step.test.ts assertions continue to pass (no regression on left/top stops, axis-slide composition, or full-stop-when-fully-blocked behavior)"
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        provides: "isFeetBboxWalkable with right/bottom corner samples normalized to EXCLUSIVE bbox boundary (matches feetBboxIntersectsAABB strict-inequality convention)"
        contains: "right - 1"
      - path: "packages/game-logic/test/collision.test.ts"
        provides: "Regression tests pinning right-edge AND bottom-edge stop pixels against tile-boundary walls in walkable-grid path; left+top symmetry tests unchanged"
        contains: "edge-off-by-one"
      - path: "packages/game-logic/test/step.test.ts"
        provides: "End-to-end step() regression: a (5,0) tick into a right-edge tile-boundary wall reaches the same final pixel under grid path as under polys path"
        contains: "parity"
    key_links:
      - from: "packages/game-logic/src/step.ts:194 (probeX + NAVI_MASK.right)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable right-corner sample"
        via: "exclusive-boundary right-corner adjustment inside isFeetBboxWalkable"
        pattern: "right - 1"
      - from: "packages/game-logic/src/step.ts:215 (probeY + NAVI_MASK.bottom)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable bottom-corner sample"
        via: "exclusive-boundary bottom-corner adjustment inside isFeetBboxWalkable"
        pattern: "bottom - 1"
  ---

  <objective>
  Close the 1-pixel edge-collision asymmetry exposed by Phase 06.7's self-player
  reconciler no-op (UAT.md gap 4, severity minor).

  Root cause (`.planning/debug/edge-collision-off-by-one.md`):
  Boundary-convention mismatch between two collision paths in
  `packages/game-logic/src/collision.ts`:
    - Client-side walkable-grid path (`isFeetBboxWalkable`, line 172) treats the
      right/bottom corner pixel as INCLUSIVE via `Math.floor(px / tile_w)`.
    - Server-side polys path (`feetBboxIntersectsAABB`, lines 69-72) treats the
      bbox boundary as EXCLUSIVE via strict `feetMaxX > b.minX`.
  The two paths disagree by exactly 1 px on MAX axes (right, bottom) and agree
  on MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path masked
  the discrepancy by snapping the local sprite to the server's polys-path stop;
  Phase 06.7 Plan 03's `isSelfPlayer` early-return in `reconciler.ts:53-54`
  removed that corrective sink, exposing the latent grid-path 1-px-short stop.

  Purpose: restore pixel-perfect parity between client prediction and server
  authority on edge collisions. Pre-existing latent bug from Plan 06.1-02
  (`0c40432`), exposed by Phase 06.7 client-trust flip.

  Output: One atomic commit normalizing the right/bottom corner samples in
  `isFeetBboxWalkable` to be EXCLUSIVE of the bbox boundary, with regression
  tests pinning the stop pixels on all four edges. Operator UAT Test 4 edge-
  collision gap re-checks PASS.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/edge-collision-off-by-one.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @packages/game-logic/src/collision.ts
  @packages/game-logic/src/step.ts
  @packages/game-logic/src/constants.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/game-logic/src/constants.ts:
  ```
  export const NAVI_MASK = { left: -9, right: 9, top: -9, bottom: 0 } as const;
  // tile_w = 44, tile_h = 40 (from CLAUDE.md extracted constants)
  ```

  From packages/game-logic/src/collision.ts:63-74 (feetBboxIntersectsAABB —
  the EXCLUSIVE-boundary reference convention to match):
  ```
  function feetBboxIntersectsAABB(pos: Vec2, b: AABB): boolean {
    const feetMinX = pos.x + NAVI_MASK.left;
    const feetMaxX = pos.x + NAVI_MASK.right;
    const feetMinY = pos.y + NAVI_MASK.top;
    const feetMaxY = pos.y + NAVI_MASK.bottom;
    return (
      feetMaxX > b.minX &&   // STRICT — corner at b.minX is OUTSIDE
      feetMinX < b.maxX &&
      feetMaxY > b.minY &&   // STRICT — corner at b.minY is OUTSIDE
      feetMinY < b.maxY
    );
  }
  ```

  From packages/game-logic/src/collision.ts:159-182 (current
  isFeetBboxWalkable — the path to FIX):
  ```
  export function isFeetBboxWalkable(
    grid: WalkableGrid,
    left: number,
    top: number,
    right: number,
    bottom: number,
  ): boolean {
    const xs: ReadonlyArray<number> = [left, right];
    const ys: ReadonlyArray<number> = [top, bottom];
    for (let xi = 0; xi < xs.length; xi++) {
      const px = xs[xi]!;
      const col = Math.floor(px / grid.tile_w);
      if (col < 0 || col >= grid.width_tiles) return false;
      for (let yi = 0; yi < ys.length; yi++) {
        const py = ys[yi]!;
        const row = Math.floor(py / grid.tile_h);
        if (row < 0 || row >= grid.height_tiles) return false;
        if (grid.cells[row * grid.width_tiles + col] !== 1) return false;
      }
    }
    return true;
  }
  ```

  From packages/game-logic/src/step.ts:187-202 (X-axis probe call site —
  caller passes feet-bbox right corner as `probeX + NAVI_MASK.right`):
  ```
  for (let i = 0; i < stepsX; i++) {
    const probeX = px + signX;
    if (
      isFeetBboxWalkable(
        grid,
        probeX + NAVI_MASK.left,
        py + NAVI_MASK.top,
        probeX + NAVI_MASK.right,    // right-corner pixel of feet bbox
        py + NAVI_MASK.bottom,        // bottom-corner pixel of feet bbox
      )
    ) { px = probeX; } else { break; }
  }
  ```

  Stop-pixel math (from `.planning/debug/edge-collision-off-by-one.md`):
    Wall at tile boundary: col N walkable, col N+1 unwalkable. tile_w = 44.
    - Polys path stop: pos.x = N*44 + 35 (last clear pixel; feetMaxX = 44N+44 = b.minX → not strictly greater).
    - Grid path stop (CURRENT BUG): pos.x = N*44 + 34 (one short; probe at +35 has col = floor((44N+44)/44) = N+1 → blocked).
    - Grid path stop (AFTER FIX, right-1 adjustment): pos.x = N*44 + 35 (probe at +35 has col = floor((44N+44-1)/44) = N → walkable; probe at +36 has col = floor((44N+45-1)/44) = floor((44N+44)/44) = N+1 → blocked).
    Bottom-edge math symmetric with tile_h = 40, NAVI_MASK.bottom = 0.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Normalize right/bottom corner samples in isFeetBboxWalkable to exclusive boundary + regression tests</name>
    <files>packages/game-logic/src/collision.ts, packages/game-logic/test/collision.test.ts, packages/game-logic/test/step.test.ts</files>
    <behavior>
      - RED (collision.test.ts):
          Add a `describe('isFeetBboxWalkable edge-off-by-one (Plan 06.7-06)', ...)` block with four `it(...)` tests:
          (a) `it('right-edge tile-boundary wall: bbox flush against wall (right corner at b.minX) is walkable', ...)` — construct a grid where col N is walkable, col N+1 is unwalkable. Call `isFeetBboxWalkable(grid, left=N*44-9, top=0, right=N*44+44, bottom=0)` (i.e., pos.x = N*44 + 35 → right corner = N*44 + 44 = boundary). Assert `true` (flush is OK under EXCLUSIVE convention).
          (b) `it('right-edge tile-boundary wall: bbox one px past wall (right corner at b.minX + 1) is blocked', ...)` — same grid. Call `isFeetBboxWalkable(grid, left=N*44-8, top=0, right=N*44+45, bottom=0)`. Assert `false`.
          (c) `it('bottom-edge tile-boundary wall: bbox flush (bottom corner at b.minY) is walkable', ...)` — col 0 walkable rows 0..M-1, row M unwalkable. Call `isFeetBboxWalkable(grid, left=0, top=M*40-9, right=18, bottom=M*40)`. Assert `true`.
          (d) `it('bottom-edge tile-boundary wall: bbox one px past (bottom corner at b.minY + 1) is blocked', ...)` — Assert `false`.
          Test (a) and (c) MUST fail initially because current `Math.floor(px / tile_w)` treats the boundary pixel as inside cell N+1 (or row M).
      - RED (step.test.ts):
          Add a `describe('step() edge-collision parity (Plan 06.7-06)', ...)` block with two `it(...)` tests asserting end-to-end stop-pixel parity between grid path and polys path:
          (e) `it('right-edge: grid-path final px matches polys-path final px after a (+5, 0) tick into tile-boundary wall', ...)` — build two PlayerSims at pos = (N*44 + 30, y0), tick +5 px right; one with `walkable_grid` set (grid path), one with `collision_polys` set (polys path). Assert both `final.players.get(sid).x === N*44 + 35`.
          (f) `it('bottom-edge: grid-path final py matches polys-path final py after a (0, +5) tick into tile-boundary wall', ...)` — analogous on Y axis. Assert both `final.players.get(sid).y === M*40 + 40`. NOTE: NAVI_MASK.bottom = 0, so the math is `pos.y = M*40` (not M*40+40). Confirm against the debug doc: pos.y = M*40 + 40 — the +40 comes from feetMaxY = pos.y + 0 = pos.y; blocked when pos.y > M*40; so last clear pos.y = M*40 exactly. UPDATE THE ASSERTION to `M*40` (debug doc's "N*40 + 40" where N = M-1, last walkable row, is the same pixel as M*40 where M = first unwalkable row). Document the indexing convention in a test comment to avoid future confusion.
          Both tests MUST fail initially: grid path returns one px short on right (and on bottom).
      - GREEN: Modify `packages/game-logic/src/collision.ts` `isFeetBboxWalkable` to make the right/bottom corner samples EXCLUSIVE of the bbox boundary. Implementation:
          Inside the function, BEFORE the `xs`/`ys` array construction, derive adjusted samples:
          ```
          // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
          // Right/bottom corner samples are EXCLUSIVE of the feet-bbox boundary,
          // matching feetBboxIntersectsAABB's strict-inequality convention
          // (collision.ts:69-72). Without this adjustment, the grid path stops the
          // player 1 px short on right+bottom edges vs the polys path — exposed
          // by Phase 06.7 self-player reconciler no-op. See
          // .planning/debug/edge-collision-off-by-one.md.
          const rightExclusive = right - 1;
          const bottomExclusive = bottom - 1;
          const xs: ReadonlyArray<number> = [left, rightExclusive];
          const ys: ReadonlyArray<number> = [top, bottomExclusive];
          ```
          Do NOT change the parameter signature — callers still pass `right` and `bottom` as the bbox boundary; the helper internally adjusts.
          Preserve existing traceable-reqs tags on the function.
      - Re-run vitest — all tests MUST pass.
      - Verify pre-existing tests (left/top symmetry, axis-slide, fully-blocked break) still pass.
    </behavior>
    <action>
      Apply fix from `.planning/debug/edge-collision-off-by-one.md` Resolution §root_cause via the in-helper adjustment option (UAT.md gap 4 missing-item #2 "adjust inside isFeetBboxWalkable for right/bottom only"). Chosen over the call-site adjustment in step.ts because:
        - Single chokepoint — no risk of new callers forgetting the subtract-1
        - Helper contract stays "pass the bbox boundary"; convention is encapsulated
        - Matches Plan 06.1-02 author intent (helper owns walkability semantics, not callers)

      Test placement:
        - collision.test.ts: append the new `describe(...)` block alongside existing isFeetBboxWalkable tests. Use existing `makeGrid` helper if one exists; otherwise inline a minimal `{ tile_w: 44, tile_h: 40, width_tiles, height_tiles, cells }` literal.
        - step.test.ts: append the new `describe(...)` block alongside existing step() tests. Reuse existing player/state builders.

      Tag all new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`.

      Commit message: `fix(06.7): normalize edge-collision grid path to match polys boundary convention`. Body must reference `.planning/debug/edge-collision-off-by-one.md` and explain the 1-px right/bottom asymmetry, the exclusive-boundary convention, and the self-player reconciler no-op as the trigger that exposed the latent bug.
    </action>
    <verify>
      <automated>pnpm -C packages/game-logic exec vitest run</automated>
    </verify>
    <done>
      All game-logic vitest tests pass (existing + 6 new edge-parity tests). `pnpm -C packages/game-logic exec tsc --noEmit` is green. `pnpm trace:check` is green. Right/bottom edge tile-boundary walls produce identical stop pixels under both grid path and polys path.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client prediction → server authority | Both run the SAME `step()` from `@rebno/game-logic`; after fix, both branches agree pixel-for-pixel on edge stops. Removes the 1-px self-prediction/server-authority drift that the self-player reconciler used to mask. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-06-01 | Tampering | client-side walkable-grid (`this.roomRenderer?.walkableGrid` passed into step) | accept | Already accepted in Phase 06.1-02. Client could fabricate a walkable grid permitting illegal positions; server doesn't consume the grid (server step takes polys branch, step.ts:226-237). Anti-cheat deferred to Phase 06.8 per Hard Rule 1 carve-out. This plan does not change the trust posture; it only removes a 1-px asymmetry that would otherwise produce server→client reconcile churn once anti-cheat lands. |
  | T-06.7-06-02 | Denial of Service | regression in axis-slide composition (Y advances from post-X position) | accept | Mitigated by pre-existing axis-slide tests in collision.test.ts and step.test.ts. New tests do NOT modify the loop structure, only the corner-sample math inside `isFeetBboxWalkable`. Composition contract preserved. |
  </threat_model>

  <verification>
    - `pnpm -C packages/game-logic exec vitest run` — green (existing + 6 new edge-parity tests).
    - `pnpm -C packages/game-logic exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (sanity, no schema change — Test 5 stale-build lesson).
    - `pnpm -C apps/client exec vitest run` — green (consumer of @rebno/game-logic; no regression on prediction tests).
    - `pnpm -C apps/server exec vitest run` — green (server step() takes polys branch — fix is logically idempotent for server, but verify nothing broke).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: walk right and down into BNCentral floor edges; sprite stops flush against the wall (no visible 1-px gap). Compare against pre-06.7 baseline screenshot if available.
  </verification>

  <success_criteria>
  - One commit lands with message `fix(06.7): normalize edge-collision grid path to match polys boundary convention`.
  - The 6 new regression tests (4 in collision.test.ts, 2 in step.test.ts) all pass.
  - All pre-existing game-logic, apps/client, and apps/server tests still pass.
  - Operator re-checks UAT Test 4 edge-collision sub-gap and reports `pass` (stop pixels flush against right/bottom walls).
  - UAT.md gap entry for "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline" flips from `status: failed` to `status: resolved`.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-06-SUMMARY.md` documenting:
  - The 1-px boundary-convention mismatch and which line fixed it (`isFeetBboxWalkable` right/bottom corner adjustment).
  - Why the in-helper option was chosen over the call-site option.
  - All 6 regression tests added (file + describe name + it name).
  - Verification commands run and their results.
  - Reference back to `.planning/debug/edge-collision-off-by-one.md` and the UAT.md gap entry.
  - Note that this also pre-empts future reconcile-churn once anti-cheat (Phase 06.8) re-enables the self-player snapshot path.
  </output>

  ## Plan 07 (protocol-build pre-flight DX hardening)
  ---
  phase: 06.7
  plan: 07
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - apps/server/package.json
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-SRV-14
  tags: [tooling, doctrine, gap-closure, protocol-build-preflight]
  must_haves:
    truths:
      - "`pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` (and any other integ test that depends on @rebno/protocol's compiled dist/) automatically builds @rebno/protocol BEFORE the test run, eliminating the stale-build false positive class of failures"
      - "The HUMAN-UAT operator runbook explicitly documents the protocol-build pre-flight requirement so manual UAT operators do not re-hit Test 5's `expected: 4400 actual: 524` false positive"
      - "The pre-flight build is fast (< 5 s for a no-op rebuild) and runs unconditionally before integ tests — no mtime heuristics that could miss edits"
      - "All other pre-existing apps/server scripts (build, test, test:integration, test:full, typecheck, migrate, dev, start) continue to work; only the integ-test entry points pick up the new pretest hook"
    artifacts:
      - path: "apps/server/package.json"
        provides: "pretest:integration / pretest:full hooks that build @rebno/protocol before running integ tests"
        contains: "pnpm -C ../../packages/protocol build"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator doctrine note at the top of the runbook citing the stale-protocol-build false positive lesson and the required pre-flight command"
        contains: "stale-protocol-build"
    key_links:
      - from: "any change to packages/protocol/src/*"
        to: "apps/server/test/protocol-v4-handshake.integ.test.ts (and other consumers of @rebno/protocol's dist/)"
        via: "apps/server/package.json `pretest:integration` / `pretest:full` npm-lifecycle hook"
        pattern: "pretest:integration"
  ---

  <objective>
  Eliminate the "stale @rebno/protocol build artifact" class of false-positive
  integ-test failures by adding an automatic pre-flight `pnpm -C packages/protocol build`
  to apps/server's integ-test entry points, plus a doctrine note in the
  HUMAN-UAT runbook for operators running tests by hand.

  Source: 06.7-UAT.md Test 5 note + Resolved (false positive) section:
    "Operator process improvement: ensure pnpm -C packages/protocol build runs
     before integ tests after any protocol schema/version change."

  The Test 5 first-run failure (`expected: 4400 actual: 524` +
  'seat reservation expired') was diagnosed as a stale @rebno/protocol build
  artifact, NOT a real defect — but it cost ~20 min of operator triage time
  and was already flagged in `.planning/debug/v3-rejection-wrong-close-code.md`
  as a recurring trap. This plan automates the fix in the test runner and
  documents the doctrine for manual operators.

  Purpose: developer-experience + UAT-reliability hardening. Prevents
  recurrence of a known false-positive class in a phase where REQ-CLI-08 hard
  milestone hangs on operator UAT signoff.

  Output: One atomic commit adding `pretest:integration` + `pretest:full` npm
  lifecycle hooks to apps/server/package.json, plus a "Pre-flight" section at
  the top of 06.7-HUMAN-UAT.md.
  </objective>

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

  <context>
  @.planning/STATE.md
  @CLAUDE.md
  @.planning/debug/v3-rejection-wrong-close-code.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  @apps/server/package.json
  @packages/protocol/package.json

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From apps/server/package.json (current scripts block — line 8-17):
  ```
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -b",
    "start": "node dist/index.js",
    "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
    "test:integration": "vitest run integ.test",
    "test:full": "vitest run",
    "typecheck": "tsc --noEmit",
    "migrate": "node dist/scripts/run-migrations.js"
  }
  ```

  npm/pnpm lifecycle: a script named `preX` runs automatically before `X`.
  `pretest:integration` runs before `test:integration`; `pretest:full` runs
  before `test:full`. The plain `test` script does NOT need a hook because it
  excludes integ tests.

  Relative path from apps/server/ to packages/protocol/:
    apps/server/  →  ../../packages/protocol/
  Verified: pnpm workspace layout at repo root, see pnpm-workspace.yaml.

  From .planning/debug/v3-rejection-wrong-close-code.md (resolution steps,
  line 93 — the exact command sequence that was the missing pre-flight):
  ```
  pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts
  ```
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="false">
    <name>Task 1: Add pretest:integration + pretest:full hooks to apps/server/package.json</name>
    <files>apps/server/package.json</files>
    <behavior>
      - Read current apps/server/package.json scripts block.
      - Insert two new entries directly above `test:integration`:
          `"pretest:integration": "pnpm -C ../../packages/protocol build",`
          `"pretest:full": "pnpm -C ../../packages/protocol build",`
        (the `pre*` lifecycle ensures these run automatically before
        `test:integration` and `test:full` respectively when invoked via pnpm.)
      - Leave the plain `"test"` script unchanged — it explicitly excludes
        integ tests, so a protocol rebuild adds no value and would slow down
        the inner-loop unit-test run.
      - Verify JSON is valid after edit: `pnpm -C apps/server pkg get scripts`.
      - Smoke the new pretest hook: from a state where packages/protocol/dist
        is intentionally stale (e.g., touch a source file under
        packages/protocol/src/ then run nothing), invoke
        `pnpm -C apps/server test:integration` and confirm the protocol build
        runs first.
          Acceptance: the integ-test command output shows the
          `> @rebno/protocol@... build` line BEFORE the vitest output.
          (If the executor cannot easily induce a stale-dist condition, it is
          sufficient to invoke `pnpm -C apps/server test:integration` and grep
          the output for the protocol-build line — it will run unconditionally
          thanks to `tsc -b`'s incremental check.)
    </behavior>
    <action>
      Edit apps/server/package.json via the Edit tool. Use a single `old_string`
      of the current scripts block and a `new_string` that inserts the two new
      lines in correct JSON order. Preserve trailing-comma discipline:
        ```
        "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
        "pretest:integration": "pnpm -C ../../packages/protocol build",
        "test:integration": "vitest run integ.test",
        "pretest:full": "pnpm -C ../../packages/protocol build",
        "test:full": "vitest run",
        ```
      Run `node -e "JSON.parse(require('fs').readFileSync('apps/server/package.json','utf8'))"` to confirm parse.
      Run `pnpm -C apps/server test:integration` and capture the first ~30 lines of output.
    </action>
    <verify>
      <automated>cd apps/server && node -e "const s = JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts; if (!s['pretest:integration'] || !s['pretest:full']) { console.error('missing pretest hooks'); process.exit(1); } console.log('hooks OK')"</automated>
      <automated>pnpm -C apps/server test:integration 2>&1 | grep -E "^(> @rebno/protocol|PASS|FAIL)" | head -5</automated>
    </verify>
    <done>
      apps/server/package.json contains both `pretest:integration` and `pretest:full` scripts that invoke `pnpm -C ../../packages/protocol build`. Running `pnpm -C apps/server test:integration` builds @rebno/protocol first, then runs vitest. Existing scripts (test, build, dev, start, typecheck, migrate) are unchanged.
    </done>
  </task>

  <task type="auto" tdd="false">
    <name>Task 2: Add Pre-flight doctrine section to 06.7-HUMAN-UAT.md</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <behavior>
      - Read the current HUMAN-UAT runbook.
      - Insert a new `## Pre-flight (run before any test)` section directly after the YAML frontmatter and the first paragraph/heading, but BEFORE the test enumeration. Content:
          ```
          ## Pre-flight (run before any test)

          After ANY edit to `packages/protocol/src/*`, rebuild the protocol
          package before running integ tests OR manual UAT against staging:

              pnpm -C packages/protocol build

          For `apps/server` integ tests (`pnpm -C apps/server test:integration`
          or `test:full`), this build is now automatic via `pretest:integration`
          / `pretest:full` hooks (Plan 06.7-07). The command is documented here
          for manual operators running `vitest run` directly or for
          cross-package consumers (apps/client, etc.).

          **Why:** UAT Test 5 (PROTOCOL_VERSION 3 client rejection) first-run
          reported `expected: 4400 actual: 524` + 'seat reservation expired'
          failures that were diagnosed as a stale @rebno/protocol build
          artifact, NOT a real defect. After `pnpm -C packages/protocol build`,
          all 3 tests passed at HEAD `9816275`. See
          `.planning/debug/v3-rejection-wrong-close-code.md`.
          ```
      - Do NOT modify any existing test entries.
      - Add a traceable-reqs `[doc->REQ-SRV-14]` tag on a line of its own at
        the bottom of the new section (REQ-SRV-14 is the protocol-version /
        handshake req — confirm in REQUIREMENTS.md before tagging; if a more
        apt req exists for "testing doctrine", use that instead).
    </behavior>
    <action>
      Use the Edit tool with a unique anchor (the YAML frontmatter closing `---` + the first H1 line of HUMAN-UAT.md) to insert the new section without disturbing existing content. Run `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` to verify insertion.
    </action>
    <verify>
      <automated>grep -n "Pre-flight\|stale-protocol-build\|pretest:integration" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
      <automated>pnpm trace:check</automated>
    </verify>
    <done>
      06.7-HUMAN-UAT.md has a "Pre-flight (run before any test)" section between the frontmatter and the first test entry, documenting the manual protocol-build command, the automatic hook for apps/server, the "why" with reference to debug session, and a `[doc->REQ-SRV-14]` (or chosen alternative) tag. `pnpm trace:check` is green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | developer workstation → npm-lifecycle hook | The new `pretest:integration` hook runs `pnpm -C ../../packages/protocol build`, which executes `tsc -b`. tsc is already a trusted dev dep; no new attack surface. The relative path `../../packages/protocol` is resolved within the pnpm workspace and cannot escape it under normal repo structure. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-07-01 | Tampering | malicious modification of pretest hook to inject arbitrary command | accept | apps/server/package.json is under version control; any change to scripts is visible in git diff and PR review. Same posture as every other npm script. |
  | T-06.7-07-02 | Denial of Service | pretest hook adds latency to inner-loop testing | mitigated | Hook applies only to `test:integration` and `test:full`. Plain `test` script (the inner-loop fast path that excludes integ tests) is unchanged. tsc incremental rebuild on a clean tree is ~1-2 s; acceptable for integ-test runs that already take 3+ s. |
  | T-06.7-07-03 | Tampering | doctrine note in HUMAN-UAT.md being skipped by an operator | accept | Plan does not require operators to read the doctrine — automated hook covers the apps/server path. The doctrine is a safety net for cross-package consumers and for manual `vitest` invocation. |
  </threat_model>

  <verification>
    - `pnpm -C apps/server pkg get scripts` — output includes `pretest:integration` and `pretest:full`.
    - `pnpm -C apps/server test:integration` — runs `@rebno/protocol build` first (visible in stdout), then runs vitest; all integ tests pass.
    - `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — returns a match.
    - `pnpm trace:check` — green.
    - No regression on other apps/server scripts: `pnpm -C apps/server typecheck` still green; `pnpm -C apps/server build` still green; `pnpm -C apps/server test` (unit only) still green.
  </verification>

  <success_criteria>
  - One commit lands with message `chore(06.7): auto-build @rebno/protocol before apps/server integ tests + UAT doctrine`.
  - `pnpm -C apps/server test:integration` invoked from a clean checkout produces a `@rebno/protocol build` line in its output BEFORE vitest starts.
  - 06.7-HUMAN-UAT.md has a Pre-flight section.
  - `pnpm trace:check` green.
  - Test 5 false-positive class will not recur on the apps/server integ path.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-07-SUMMARY.md` documenting:
  - The two pretest hook entries added.
  - The HUMAN-UAT doctrine section added (with line numbers).
  - A short rationale why pretest hooks were preferred over a root-level alias or a `prepare` script.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/v3-rejection-wrong-close-code.md` and the UAT.md "Resolved (false positive)" entry.
  - A note that this is a DX/UAT-reliability hardening plan — no production code or wire-protocol change.
  </output>


  ## Review Instructions

  For each plan (05, 06, 07) provide:

  1. **Summary** — One-paragraph assessment.
  2. **Strengths** — bullet points.
  3. **Concerns** — bullet points with severity HIGH/MEDIUM/LOW.
  4. **Suggestions** — specific, actionable.
  5. **Risk Assessment** — LOW/MEDIUM/HIGH overall with justification.

  Then add a **Cross-Plan Section** covering:
  - Whether the 3 plans together fully close the UAT.md gap entries (4 gaps total: 3 blocker + 1 minor; Test 5 is already a false positive resolved)
  - Whether wave parallelism is safe (Plan 05 = apps/client, Plan 06 = packages/game-logic, Plan 07 = apps/server + .planning docs — claim is "disjoint files, safe to parallelize")
  - Whether anything is missing that should be a Plan 08 (e.g., a test that pins the 1-px reconcile delta won't return when Phase 06.8 anti-cheat re-enables self-player snapshot, a guard against the inputDispatcher.axisX() type drift mentioned as a Plan 05 nit)
  - Hard Rule 1 carve-out compliance (movement client-authoritative, everything else server-authoritative)

  Be terse and concrete. No filler. Output markdown.

preview:
  # Cross-AI Plan Review Request — REBNO Phase 06.7 Gap-Closure Plans 05/06/07

  You are reviewing THREE gap-closure plans for Phase 06.7 of REBNO (a Chrome rebuild of an early-2000s GameMaker 5.3a multiplayer game).

  The phase's first 4 plans (01-04) shipped a CLIENT-AUTHORITATIVE movement model: clients send `position_update` intents at 30 Hz; server stores/broadcasts without semantic validation. Phases 01-04 were merged at HEAD 9816275. Operator HUMAN-UAT then found 2 blocker-class issues + 1 minor issue. Plans 05/06/07 close those gaps.

  Focus your review on whether Plans 05/06/07 actually close the gaps without introducing new defects, scope creep, or coverage holes. Plans 01-04 are out of scope (already executed).

  ## Project Context (CLAUDE.md hard rules + extracted constants)

  # CLAUDE.md — REBNO Project Instructions

  ## Project

  **REBNO** — ground-up rebuild of *BN Online* (a late-1990s/early-2000s GameMaker 5.3a multiplayer game) on a modern web stack. Target: Chrome users walk around and chat in real time, then full feature parity with the original.

  **Core Value:** Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.

  ## Source-of-Truth Files

  Everything depends on two files in `legacy/open-source-release/`:

  - `BN Online Client 5-8.gmd` — latest client revision
  - `BN Online Master 5-4.gmd` — latest server revision

  Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only. Do not target them as primary inputs.

  ## Repo Layout

  | Path | Purpose |
  |---|---|
  | `decomp/wiki/` | 17-doc reverse-engineering knowledge base for GameMaker 5.3a — read these before reasoning about extraction, the `.gmd` format, the 39dll wire protocol, or `.bno`/`.bnb`/`.bnu` save data |
  | `legacy/open-source-release/` | Authoritative source `.gmd` files (the two listed above) |
  | `legacy/source-archive/`, `legacy/servers/` | Older revisions — reference only |
  | `legacy/unity-project/`, `legacy/maya-project/` | Prior abandoned remake attempts and original Maya assets — informative, not authoritative |
  | `legacy/audio/`, `legacy/open-source-release-extras/` | Original audio + assorted artifacts |
  | `.planning/` | GSD workflow state — see below |
  | `.planning/codebase/` | 7 docs mapping the existing brownfield codebase |
  | `.planning/research/` | 5 docs of project-level research (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY) |

  ## Tech Stack (decided)

  - **Server**: Node 22 + TypeScript + Colyseus 0.17 + `ws` + Better-Auth + argon2id + better-sqlite3 + Litestream
  - **Client**: TypeScript + Vite — engine = Phaser 3 (preferred) OR PixiJS (fallback). Final choice locked by ADR at end of Phase 2.
  - **Transport**: WebSocket binary frames; authoritative server; clients send intent
  - **Persistence**: SQLite + Litestream → Tigris bucket on Fly.io. Postgres only if Phase 3 surfaces a relational requirement.
  - **Hosting**: Fly.io single machine, persistent volume, <50 CCU target
  - **Monorepo**: pnpm workspaces. Shared packages: `protocol` (wire types + codec) and `game-logic` (pure deterministic simulation, runs in Node + browser identically).

  See `.planning/research/STACK.md` for version pins, alternatives matrix, and Fly.io specifics.

  ## Extracted Constants (load-bearing — never assume otherwise)

  | Constant | Value | Source of truth |
  |---|---|---|
  | Floor tile dimensions | **44 × 40 px** (W × H) | `extracted/client-5-8/sprites/0023-Tile1/meta.json` |
  | Room tick rate | 30 steps/sec | `extracted/client-5-8/rooms/*/meta.json` `speed: 30` |
  | View / camera viewport | 640 × 480 px | `extracted/client-5-8/rooms/*/meta.json` `views[0].viewW/viewH` |
  | BNCentral room dimensions | 8000 × 6400 px | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` |
  | MVP synthetic room (20×20 tiles) | 880 × 800 px | derived: 20·44 × 20·40 |

  **NEVER hard-code 32×32 or any other tile size.** GameMaker's editor defaults are misleading; BNO's tile pitch is 44×40. Every renderer, collision grid, asset-pipeline atlas slicer, and test-room generator must read these from extracted `meta.json` or use the constants above. See [`docs/extracted-engine/scene-room-model.md`](docs/extracted-engine/scene-room-model.md) "Canonical floor tile" for full derivation.

  ## Coordinate Conventions (D-63 — Plan 06.4)

  The legacy GameMaker engine and our Phaser renderer use different sprite-origin
  conventions. Player-attached effects (TeleIn, TeleOut, HexportIn/Out, ncol*,
  jokershell, watching) must convert between them.

  | Engine | Navi sprite origin | Effect placement |
  |--------|---------------------|------------------|
  | Legacy GM (5.3a) | `(0, 0)` = top-left (head) | effect drawn at legacy `(x, y)` = Navi top-left |
  | Phaser (REBNO)   | `(0.5, 1)` = bottom-center (feet) | sprite.x = Navi center, sprite.y = visible feet line (NOT sprite-rect bottom) |

  **Convention shift pin:**
  - `legacy_x = phaser_x - NAVI_WIDTH_PX/2 = phaser_x - 18`
  - `legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y = phaser_y - 46`

  `NAVI_VISIBLE_FEET_Y = 46` is the bbox-bottom of NaviStandD, NOT 48 (sprite-rect
  height). The two-pixel difference (rows 46-47 in the sprite rect) is the
  transparent foot-shadow region below the visible feet — including those rows
  in the conversion places effects two pixels too low (operator UAT 2026-05-14).

  **D-55c round-2 (06.4 round-2 UAT 2026-05-15 on commit 63b022f) effect-nudge:**
  `EFFECT_NUDGE_X_PX = -1`, `EFFECT_NUDGE_Y_PX = +2` are added inside
  `phaserOriginForLegacyPlayerAttached`. Phaser origin convention: increasing
  originX shifts the rendered sprite LEFT (`sprite-left = sprite.x - originX * width`);
  increasing originY shifts the rendered sprite UP. Cycle-5 set
  `EFFECT_NUDGE_X_PX = +1` (originX = 34/64) to correct a 1 px right deviation
  on staging 5fbb357; round-2 UAT on commit 63b022f reported TeleIn 2 px too
  LEFT — i.e., cycle-5 over-corrected. To shift the sprite 2 px RIGHT we
  DECREASE originX-numerator by 2: net `EFFECT_NUDGE_X_PX = -1`, originX = 32/64
  = 0.5. `EFFECT_NUDGE_Y_PX` stays at +2 (vertical good per operator).
  Round-2 TeleIn-variant origins are `[32/64, 90/100] = [0.5, 0.9]`. The bare
  `NAVI_WIDTH_PX = 36` / `NAVI_VISIBLE_FEET_Y = 46` constants stay anchored to
  the extracted meta.json — drift detection against
  `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` continues to assert
  those source-of-truth values.

  **Canonical entry:** `apps/client/src/render/legacy-origin.ts` exports
  `phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]`.
  All player-attached effect ports MUST use this helper. Inline origin math in
  `apps/client/src/render/` is rejected by code review (HARD gate 5).

  **Source of truth:** `extracted/client-5-8/sprites/0000-NaviStandD/meta.json`
  (width=36, height=48, bboxBottom=46). The atlas-mvp.json frame metadata is
  asserted against these constants by the unit test
  `apps/client/src/__test__/legacy-origin.test.ts` (drift-detection — fails CI
  if atlas regeneration produces different Navi metrics).

  [doc->REQ-CLI-04] [doc->REQ-CLI-08]

  ## Hard Rules

  1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]
  2. **No faithful port of plaintext passwords.** Original `localList.txt` plaintext archive is a security incident, not a feature. argon2id from packet 1.
  3. **No faithful port of "run clipboard as superuser" admin.** Original RCE-as-a-feature admin model is replaced by a separate authenticated web UI in Phase 7.
  4. **`.bno` / `.bnb` / `.bnu` parsing requires extracted GML first.** These formats only exist as the call order of `file_bin_*` ops in the project's own GML. Do NOT hex-inspect; extract Phase 1, derive Phase 3, parse Phase 4+.
  5. **39dll wire protocol = call order.** Same principle. Reverse it from `write*`/`read*` sequences in extracted Master GML, not from packet captures.
  6. **Extract → document → rewrite, in that order.** Phases 1-3 produce schemas; Phases 4+ consume them. No new TypeScript before Phase 4.
  7. **Modern decompilers (UTMT, Altar.NET) cannot read GM 5.3a.** Use era-appropriate tools: GM Decompiler v2.1, GMD-Recovery, LateralGM, in a WinXP VM. See `decomp/wiki/13-modern-tool-incompat.md`.
  8. **Repo stays private through Phase 7.** BNO is Capcom-derived (Mega Man Battle Network). Legal/IP path deferred. Verify `legacy/` is not pushed publicly until cleared.

  ## GSD Workflow

  This project uses the GSD (Get Shit Done) phased-execution system. Workflow state lives in `.planning/`:

  - `PROJECT.md` — project context, requirements, constraints, decisions (read first)
  - `REQUIREMENTS.md` — 49 v1 requirements with REQ-IDs, traceability table
  - `ROADMAP.md` — 7 phases with goals, requirement mapping, success criteria
  - `STATE.md` — current phase + status (read to know where work is)
  - `config.json` — workflow preferences (mode=yolo, granularity=standard, parallelization=on, model_profile=quality, research=on, plan_check=on, verifier=on)
  - `research/` — project-level research outputs (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY)
  - `codebase/` — codebase map (STACK, INTEGRATIONS, ARCHITECTURE, STRUCTURE, CONVENTIONS, TESTING, CONCERNS)

  ### Workflow Discipline

  - **Per-phase flow**: `/gsd-discuss-phase N` → `/gsd-plan-phase N` → `/gsd-execute-phase N` → `/gsd-verify-work N`
  - **Atomic commits**: Each phase commits its artifacts immediately. Use `gsd-sdk query commit "msg" <files>`.
  - **Phases 1-3 do NOT parallelize with Phase 4+.** Within a phase, parallel plan execution is fine.
  - **Hard milestone**: CLI-08 in Phase 6 — two players move + chat over deployed server. Entire rebuild gates here.
  - **Research-flagged phases** (1, 3, 7) need deeper per-phase research at planning time.

  ## Phase Roadmap (high level)

  1. **Extraction** — both `.gmd` → diffable per-resource trees (8 reqs)
  2. **Client Engine Documentation** — document client engine + Phaser/Pixi ADR (4 reqs)
  3. **Server Documentation & Schemas** — reverse 39dll, save formats, persistence ADR, closed parity checklist (6 reqs)
  4. **Server Rebuild (MVP)** — Node + Colyseus authoritative server for movement+chat slice (12 reqs)
  5. **Deploy** — Fly.io + Litestream + CI/CD + restore runbook (8 reqs)

  ## UAT Findings (06.7-UAT.md)
  ---
  status: diagnosed
  phase: 06.7-network-model-client-trust-fall-trigger
  source: [06.7-01-SUMMARY.md, 06.7-02-SUMMARY.md, 06.7-03-SUMMARY.md, 06.7-04-SUMMARY.md, 06.7-HUMAN-UAT.md]
  started: 2026-05-17T10:18:59Z
  updated: 2026-05-17T10:34:30Z
  ---

  ## Current Test

  [testing complete]

  ## Tests

  ### 1. Cold Start Smoke Test
  expected: Kill running server/client. Start fresh. Server boots clean, client handshake succeeds at PROTOCOL_VERSION=4, join BNCentral returns live state.
  result: pass
  note: Operator connected two clients to staging successfully; cold-start path implicitly validated by subsequent multiplayer test.

  ### 2. Diagonal-stop drift bug closed (D-02a)
  expected: Log in to BNCentral spawn. Hold W+D for 2 s for up-right diagonal. Release both keys simultaneously. Sprite stops at its current pixel position with no horizontal shift and no ~10 px snap right or left.
  result: pass
  reported: "diagonal stop drift fixed"

  ### 3. Dropped-packet hitching closed (D-02b)
  expected: DevTools → Network → Throttling = Slow 3G (or custom ~5% loss / 150 ms RTT). Hold W to run north 5 s. Continuous smooth movement.
  result: pass
  reported: "no more dropped-packet hitching, silky smooth for local movement"

  ### 4. Two-player smoke (REQ-CLI-08 hard milestone regression)
  expected: Operator A + B in two browsers join BNCentral. Each sees the other's sprite and smooth motion (no rubber-band, no drift), running animation plays, velocity is applied. Chat appears with correct origin.
  result: issue
  reported: "client of 2nd joiner doesn't see any remote player position or sprite updates; client of 1st joiner does see player position updates, but they're super slow, inconsistent timing, facing direction comes through but never sprite change (running anim never plays), velocity is never applied; chat works"
  severity: blocker

  ### 5. PROTOCOL_VERSION 3 client rejection — AUTOMATED COVERAGE (D-12)
  expected: `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` exits 0. v3 rejected with close-code 4400 / PROTOCOL_VERSION_MISMATCH; v4 accepted.
  result: pass
  note: First run produced 524≠4400 + 'seat reservation expired' failures — diagnosed as stale packages/protocol build artifact. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275 (3.34s, 0 failures). Server correctly throws ServerError(4400, "PROTOCOL_VERSION_MISMATCH ...") at apps/server/src/RebnoRoom.ts:394-398.

  ### 6. Rollback drill (D-14)
  expected: Confirm documented rollback target references the four Phase 06.7 plan commits.
  result: pass
  note: Verified in 06.7-04-SUMMARY.md "Rollback Target" section — lists Plan 01 (1ba1fe6), Plan 01 summary (086bf71), Plan 02 (f9b02e3), Plan 03 (8ba0f9b), Plan 04 docs commit. Four production commits accounted for.

  ### 7. Idle-cadence steady 30 Hz network confirm (D-06)
  expected: Idle BNCentral 5 s with WS frame panel open. ~150 position_update frames (120–180 range), each with vx=0/vy=0, no gaps >100 ms.
  result: skipped
  reason: Operator did not run DevTools WS frame measurement during this UAT pass. Automated coverage in apps/client/src/__test__/position-dispatcher.test.ts is the standing guard; manual measurement deferred until Test 4 blocker resolved (remote-sync issues may also indicate dispatcher cadence problems).

  ## Summary

  total: 7
  passed: 4
  issues: 1
  pending: 0
  skipped: 1
  blocked: 0
  new_regressions: 1
  gaps_diagnosed: 5

  ## Gaps

  - truth: "Each peer sees the other's player position, velocity, facing, and animation state in near-real-time"
    status: failed
    reason: "User reported: client of 2nd joiner doesn't see ANY remote player position or sprite updates from 1st; 1st sees super slow inconsistent updates from 2nd"
    severity: blocker
    test: 4
    root_cause: "RC #1 — Wire-schema vs runtime-timestamp mismatch. cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) declares monotonic_at_ms as .int(); PositionDispatcher passes Phaser fractional rAF _time arg unmodified (position-dispatcher.ts:30-46, GameScene.ts:1193 sendTick(_time)). Every frame fails zod parse → server logs invalid_intent and silently drops (onMessageHandlers.ts:315-324). Only frames landing on integer-ms boundaries pass — hence super-slow inconsistent timing. For 2nd joiner viewing 1st (mostly idle pre-join), nearly all updates dropped → sprite frozen at spawn → 'sees nothing'."
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 30-46 pass monotonic_at_ms through unmodified, unlike x/y/vx/vy which ARE rounded"
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Line 1193 passes Phaser fractional _time to sendTick"
      - path: "packages/protocol/src/intents.ts"
        issue: "Lines 125-137 .int() constraint on monotonic_at_ms (correct — wire contract)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        issue: "Unit tests use integer time args (sendTick(1), sendTick(1234)) — masked runtime drift from CI"
    missing:
      - "Apply Math.floor(monotonic_at_ms) at position-dispatcher.ts:42 (preserve u32-int wire contract)"
      - "Add regression test: dispatcher.sendTick(1234.567) → payload monotonic_at_ms === 1234"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player velocity (vx, vy) is applied to position interpolation/extrapolation"
    status: failed
    reason: "User reported: velocity is never applied"
    severity: blocker
    test: 4
    root_cause: "RC #2 — PredictionEngine.getLocalState() always returns vx=0, vy=0. step.ts:243-251 (with-input branch) hardcodes vx:0, vy:0 per BNO instant-set model (ACCEL=0, FRICTION=0). predictor.ts:113-114 propagates to localState. position-dispatcher.ts:37-38 Math.round(local.vx/vy) → always 0 on wire. Server echoes 0; remote renderer never animates."
    artifacts:
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 243-251 with-input branch returns vx:0, vy:0 unconditionally"
      - path: "apps/client/src/prediction/predictor.ts"
        issue: "Lines 113-114 propagate step's vx=0/vy=0 to localState"
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        issue: "Lines 37-38 read local.vx/vy which are always 0"
    missing:
      - "Inject axis-vector callback into PositionDispatcher (parallel to getFacing / getIsRunning)"
      - "Compute vx = round(axisX * RUN_SPEED_PX_PER_TICK), vy = round(axisY * RUN_SPEED_PX_PER_TICK) — mirror GameScene.ts:1199-1200 pattern used for local renderer's onSimulationTickLocal"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Remote-player sprite plays running animation when vx/vy != 0"
    status: failed
    reason: "User reported: facing comes through but sprite change never happens, running anim never plays"
    severity: blocker
    test: 4
    root_cause: "RC #3 — Remote-render reads vx/vy only, ignores broadcast anim_state. GameScene.ts:1213-1226 remote-loop calls playerRenderer.onSimulationTickRemote(sessionId, p.vx, p.vy, p.x, p.y); never consumes p.anim_state. PlayerRenderer.ts:463-470 derives isRunning = hypot(vx,vy) > 0 — with vx=0/vy=0 from RC #2, always Stand frame. Dispatcher DOES author anim_state correctly via packAnimState(facing, getIsRunning()) and PlayerState.anim_state IS @type-decorated; consumer just ignores it."
    artifacts:
      - path: "apps/client/src/scenes/GameScene.ts"
        issue: "Lines 1213-1226 remote-sim-tick loop ignores p.anim_state"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        issue: "Lines 463-470 derive isRunning from vx/vy instead of consuming explicit anim_state"
    missing:
      - "Pass unpackAnimState(p.anim_state).running as new arg to onSimulationTickRemote (principled — server anim_state is single source of truth)"
      - "OR rely on RC #2 fix making vx/vy non-zero during movement (cheaper consequence of RC #2)"
    debug_session: ".planning/debug/two-player-remote-sync-broken.md"

  - truth: "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline"
    status: failed
    reason: "User reported: right-edge and bottom-edge floor collision both stop one pixel farther away (on the respective axis) than before"
    severity: minor
    test: 4
    root_cause: "Boundary-convention mismatch between two collision paths in packages/game-logic/src/collision.ts. Client-side walkable-grid path (isFeetBboxWalkable line 172) treats feet-bbox right/bottom corner pixel as INCLUSIVE via Math.floor(px/tile_w); legacy polys path (feetBboxIntersectsAABB lines 69-72) treats it as EXCLUSIVE via strict feetMaxX > minX. The two paths produce stops differing by exactly 1 px on MAX axes (right, bottom) and agree on MIN axes (left, top) — matches operator report exactly. Pre-existed since Plan 06.1-02 (0c40432) but was masked: pre-06.7 ReconcileEngine.onServerSnapshot called predictor.applyServerSnapshot for self-player, replacing predicted position with server polys-resolved position. Phase 06.7 Plan 03 (8ba0f9b) added early-return for self-player in reconciler.ts:53-54 — removed corrective sink, exposed grid-path 1-px-short stop."
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        issue: "Line 172 — Math.floor in isFeetBboxWalkable causes inclusive-corner asymmetry (load-bearing line)"
      - path: "packages/game-logic/src/collision.ts"
        issue: "Lines 63-74 — feetBboxIntersectsAABB uses strict inequality (other half of mismatch)"
      - path: "packages/game-logic/src/step.ts"
        issue: "Lines 179-225 — branch selector routes client to grid path"
      - path: "apps/client/src/prediction/reconciler.ts"
        issue: "Lines 53-54 — isSelfPlayer early-return that exposed pre-existing asymmetry"
    missing:
      - "Normalize boundary conventions: make isFeetBboxWalkable's right/bottom corner sample exclusive of bbox boundary"
      - "Subtract 1 from right/bottom probe pixels at call sites step.ts:194-195 and step.ts:214-215 (intent explicit there); OR adjust inside isFeetBboxWalkable for right/bottom only"
      - "Add regression test pinning right-edge AND bottom-edge stop pixels against tile-boundary wall for known walkable grid"
    debug_session: ".planning/debug/edge-collision-off-by-one.md"

  ## Resolved (false positive)

  - truth: "PROTOCOL_VERSION 3 client rejection emits close-code 4400 with PROTOCOL_VERSION_MISMATCH message"
    status: resolved_no_fix
    reason: "Initial run reported AssertionError: expected 524 to be 4400. Diagnosed as stale packages/protocol build. After `pnpm -C packages/protocol build`, all 3 tests pass at HEAD 9816275. Server correctly throws ServerError(4400, 'PROTOCOL_VERSION_MISMATCH ...') at apps/server/src/RebnoRoom.ts:394-398; @colyseus/ws-transport preserves 4400 through to SDK MatchMakeError.code."
    severity: n/a
    test: 5
    debug_session: ".planning/debug/v3-rejection-wrong-close-code.md"
    note: "Operator process improvement: ensure pnpm -C packages/protocol build runs before integ tests after any protocol schema/version change."

  ## Debug Session 1 — two-player-remote-sync-broken.md
  ---
  status: diagnosed
  trigger: "Two-player smoke test FAILED post Phase 06.7 client-trust flip — 2nd joiner sees nothing from 1st; 1st sees 2nd's updates super slow with no anim change and no velocity applied"
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Three layered root causes — (#1) monotonic_at_ms .int() vs fractional Phaser timestamp rejects most frames; (#2) PredictionEngine always returns vx=0/vy=0 due to BNO instant-set model in step(); (#3) remote render ignores broadcast anim_state and re-derives anim from vx/vy (which are 0 from #2)
  test: Static code analysis of server handler + client dispatcher + remote-render + step()
  expecting: All three are visible in current code; tests do not catch them because mocks bypass each defect
  next_action: Return diagnosis to caller (find_root_cause_only mode)

  ## Symptoms

  expected: Two players in BNCentral both see each other's sprite positions, velocity-driven movement, running animation when other moves; chat works for both
  actual:
    - 2nd joiner sees NOTHING of 1st joiner's movement/sprite (sprite may not even be visible)
    - 1st joiner sees 2nd's updates SLOWLY with inconsistent timing
    - 1st joiner: facing updates work, but sprite never animates (running anim doesn't play)
    - 1st joiner: velocity (vx, vy) is never applied to remote sprite rendering
    - Chat works both directions (so connection + broadcast generally healthy)
  errors: None (no console errors, no test errors — purely operator observation)
  reproduction: HUMAN-UAT Test 4. Two browsers, two accounts, join BNCentral, walk W/D/S/A square, each observes other
  started: After Phase 06.7 client-trust flip (plans 02 + 03). Pre-06.7 worked.

  ## Eliminated

  - hypothesis: PlayerState fields not @type-decorated for sync (so anim_state / sprite_override never reach peers)
    evidence: packages/protocol/src/state.ts:57-58 — both fields ARE @type('number') / @type('string'). Colyseus state-sync will broadcast them.
    timestamp: investigation

  - hypothesis: onJoin fails to deliver existing-player state to late-joiner
    evidence: Colyseus 0.17 delivers initial state-sync containing all entries in state.players to a new client automatically (no app code needed). The onMessageHandlers PlayerState fields are server-set in onJoin BEFORE state.players.set, so a late-joiner WILL receive 1st-joiner's PlayerState in their initial state-sync.
    timestamp: investigation

  - hypothesis: PositionDispatcher never fires for 2nd joiner (dispatcher not attached)
    evidence: GameScene.onLocalJoin (apps/client/src/scenes/GameScene.ts:676) creates the dispatcher when `!this.positionDispatcher && this.room && this.prediction`. Local onLocalJoin runs unconditionally; dispatcher is wired before update() loop calls sendTick.
    timestamp: investigation

  - hypothesis: Colyseus $(player).onChange() doesn't register correctly during initial state-sync
    evidence: bindHandlers in colyseus-client.ts:251 registers $(player).onChange synchronously inside players.onAdd; per Colyseus 0.17 docs onAdd auto-fires for existing items, registering listeners.
    timestamp: investigation

  - hypothesis: server `applyToColyseusState` overwrites client-written PlayerState fields
    evidence: packages/game-logic/src/step.ts:68-83 — when input is undefined (always the case in RebnoRoom.tickLoop now), step() preserves x/y/vx/vy/last_input_seq exactly. applyToColyseusState rewrites those identical values — no overwrite.
    timestamp: investigation

  ## Evidence

  - timestamp: investigation
    checked: packages/protocol/src/intents.ts line 125-137 (cPositionUpdateSchema definition)
    found: monotonic_at_ms uses `.finite().int().min(0).max(0xffffffff)` — STRICT integer required. Plan 01-SUMMARY notes this was deliberate ("closes codex review concern #7"). x/y/vx/vy all `.int()` too.
    implication: Any fractional monotonic_at_ms causes zod parse failure → server logs `invalid_intent` and DROPS the frame.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:30-46 (PositionDispatcher.sendTick)
    found: `monotonic_at_ms` is passed through to the payload UNMODIFIED. x/y/vx/vy are Math.round'd. monotonic_at_ms is NOT rounded.
    implication: Whatever the caller passes goes directly to the wire.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1193 (sim-tick call to sendTick)
    found: `this.positionDispatcher?.sendTick(_time)` — passes Phaser's `_time` (the first arg to `update(_time, dt)`), which is the rAF/performance.now-derived high-resolution timestamp. In modern browsers this is fractional ms (e.g. 1234.567).
    implication: Every position_update frame fails `monotonic_at_ms.int()` UNLESS the browser returns an integer ms (rare modes: 1ms-clamped non-cross-origin contexts, throttled background tabs). Hence "super slow with inconsistent timing" — only the occasional integer-tagged frame is accepted.

  - timestamp: investigation
    checked: apps/client/src/__test__/position-dispatcher.test.ts
    found: All unit tests call `dispatcher.sendTick(1)`, `sendTick(2)`, `sendTick(1000)`, `sendTick(1234)` — INTEGER arguments. Tests never exercise the realistic fractional-timestamp path.
    implication: The defect ships green-tested. Server handler integration tests also use synthetic integer timestamps. Drift between test fixture and runtime call-site.

  - timestamp: investigation
    checked: packages/game-logic/src/step.ts:243-251 (with-input branch of step)
    found: After integrating displacement, `newPlayers.set(...)` writes `vx: 0, vy: 0` ALWAYS. Comment line 240-242: "BNO has no velocity accumulation (ACCEL=0, FRICTION=0 — instant set/stop). vx/vy are kept at 0 in the BNO-faithful model".
    implication: PredictionEngine.predictTick() consumes step() and assigns `this.localState = next.players.get(...)`. After any predict tick, localState.vx and localState.vy are 0. PositionDispatcher.sendTick reads `local.vx, local.vy` → always sends vx=0, vy=0 on the wire.

  - timestamp: investigation
    checked: apps/server/src/onMessageHandlers.ts:354-360 (position_update handler)
    found: When a frame DOES pass zod (rare due to issue #1), server writes `player.vx = parsed.data.vx` (=0) and `player.vy = parsed.data.vy` (=0). PlayerState.vx/vy stays at 0 indefinitely for every player.
    implication: Server's authoritative PlayerState.vx/vy is always 0. Colyseus state-sync broadcasts vx=0, vy=0 to every peer.

  - timestamp: investigation
    checked: apps/client/src/scenes/GameScene.ts:1213-1226 (sim-tick remote loop)
    found: For each non-local player in state.players, calls `playerRenderer.onSimulationTickRemote(sid, p.vx ?? 0, p.vy ?? 0, p.x ?? 0, p.y ?? 0)`. Does NOT consume `p.anim_state`. Comment line 1209-1212: "Remote players consume PlayerState.vx / .vy directly. Pre-06.7 derived velocity from held-axis fields, but server Path A no longer writes those fields; position_update writes vx/vy as px/tick."
    implication: Remote-render derives running flag from vx/vy. Both are always 0 (from issue #2). deriveFrameWithAuthoritativeFacing returns a Stand-* frame → running anim NEVER plays for any remote player. The broadcast PlayerState.anim_state (which IS correctly authored per-tick by the dispatcher's `packAnimState(facing, this.getIsRunning())` from input axes — bug-free at the dispatcher) is COMPLETELY UNUSED on the consumer side.

  - timestamp: investigation
    checked: apps/client/src/render/PlayerRenderer.ts:434-470 (onSimulationTickRemote internals)
    found: deriveFrameWithAuthoritativeFacing(renderVx, renderVy, ...) — when renderVx=0 and renderVy=0, isRunning=false → Stand frame. Confirmed by line 476 "isFirstRemote → frameKey published as 0000-NaviStandD_000-style key".
    implication: Even with correct broadcast anim_state, the renderer ignores it.

  - timestamp: investigation
    checked: apps/client/src/prediction/position-dispatcher.ts:38 (getIsRunning callback origin)
    found: GameScene.ts:681-685 builds getIsRunning from inputDispatcher.axisX()/axisY() ≠ 0. This correctly reports running based on user input — the running-flag bit in anim_state IS authored correctly. The dispatcher's anim_state byte on the wire is correct.
    implication: anim_state is correct end-to-end on the wire. The defect is that the consumer never reads it.

  ## Resolution

  root_cause:
    Three layered root causes account for ALL observed symptoms:

    #1 (PRIMARY — explains "super slow, inconsistent timing" + "2nd joiner sees nothing"):
    cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) requires
    `monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)` — strict integer.
    GameScene.ts:1193 calls `positionDispatcher?.sendTick(_time)` passing Phaser's `_time`
    (a fractional high-resolution timestamp from rAF). PositionDispatcher passes the value
    through unmodified to the wire payload. Server zod-parses → fails `.int()` → drops the
    frame with `invalid_intent` warn. Effectively 100% of position_updates are rejected
    except in rare browser modes where performance.now() returns 1-ms-clamped integers
    (background tabs, certain incognito modes). On staging this produces the
    "super slow, inconsistent timing" symptom. For the 2nd joiner viewing the 1st (who
    was at spawn and hadn't moved much before the 2nd joined), most/all updates are
    dropped → 1st's sprite appears frozen at spawn coords → "sees nothing of 1st".

    #2 (explains "velocity is never applied"):
    PredictionEngine.getLocalState() returns the post-predictTick `localState`, which is
    the `PlayerSim` returned by step(). Per packages/game-logic/src/step.ts:243-251
    (with-input branch) and BNO instant-set model, step() ALWAYS returns vx=0, vy=0.
    PositionDispatcher.sendTick (apps/client/src/prediction/position-dispatcher.ts:37-38)
    reads `local.vx, local.vy` from getLocalState → always sends vx=0, vy=0 on the wire.
    Server stores vx=0, vy=0 in PlayerState. Colyseus broadcasts vx=0, vy=0 to peers.

    #3 (explains "running animation never plays"):
    GameScene.ts:1213-1226 sim-tick remote loop reads `p.vx ?? 0, p.vy ?? 0` (which from
    #2 are always 0 even when the source position_update wasn't rejected) and ignores
    `p.anim_state` entirely. PlayerRenderer.onSimulationTickRemote derives the running
    flag from renderVx/renderVy → Stand frame always. The broadcast anim_state byte
    (which IS correctly authored by PositionDispatcher.sendTick line 40
    `packAnimState(facing, this.getIsRunning())`) is unused on the consumer side.

  fix:
    Fixes are NOT applied — caller requested find_root_cause_only.

    Suggested fix direction (for Plan 06.8 or 06.7-hotfix):

    Fix #1 — choose ONE:
      (a) Coerce monotonic_at_ms to integer at the dispatcher boundary:
          apps/client/src/prediction/position-dispatcher.ts line 42 →
          `monotonic_at_ms: Math.floor(monotonic_at_ms)`. Simplest, minimal-blast-radius.
      (b) Loosen the schema in packages/protocol/src/intents.ts line 135 to
          `z.number().finite().nonnegative().max(0xffffffff)` (drop `.int()`). Revisits
          codex review concern #7 — only acceptable if the u32 binary contract is
          relaxed for the JSON-over-msgpack wire path.
      Preferred: (a). Keeps the strict int() invariant in the protocol while making the
      actual call-site honor it. Add a regression test in position-dispatcher.test.ts that
      calls `dispatcher.sendTick(1234.567)` and asserts the payload's monotonic_at_ms is
      1234 (or 1235, whichever rounding is chosen).

    Fix #2: PositionDispatcher must send the wire vx/vy that represents CURRENT input
    intent, NOT the post-step PlayerSim's BNO-instant-set vx=0. Replace the
    `Math.round(local.vx) / Math.round(local.vy)` reads with the same intent-axes source
    as `getIsRunning` already uses:
      `vx: Math.round((this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK)`
      `vy: Math.round((this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK)`
    This will require injecting an axis-callback into PositionDispatcher (parallel to the
    existing `getFacing` and `getIsRunning` callbacks). Mirrors the pattern at
    GameScene.ts:1199-1200 for `onSimulationTickLocal`.

    Fix #3: Remote-render must consume anim_state (the canonical signal) instead of
    re-deriving from vx/vy. Either:
      (a) Decode p.anim_state with unpackAnimState(byte).running and pass it as an
          explicit `isRunning` arg to onSimulationTickRemote (new arg). Renderer uses the
          running flag directly instead of computing from vx/vy hypot.
      (b) Continue to consume vx/vy but ensure they correctly carry intent (after fix #2
          this becomes a non-issue because vx/vy will be non-zero during movement).
      Both are valid. (a) is more principled (single source of truth on the wire);
      (b) is a side-effect of fix #2 and may be sufficient by itself.

  verification: (not applied — diagnose only)
  files_changed: []

  ## Files Involved

  - packages/protocol/src/intents.ts:125-137 — cPositionUpdateSchema with .int() on monotonic_at_ms (root cause #1 location)
  - apps/client/src/prediction/position-dispatcher.ts:30-46 — sendTick passes fractional monotonic_at_ms through (root cause #1 propagation point); reads vx/vy from prediction (root cause #2)
  - apps/client/src/scenes/GameScene.ts:1193 — calls sendTick(_time) with fractional Phaser timestamp (root cause #1 call site)
  - apps/client/src/scenes/GameScene.ts:1213-1226 — remote sim-tick loop reads vx/vy only, ignores anim_state (root cause #3)
  - packages/game-logic/src/step.ts:243-251 — BNO instant-set model returns vx=0/vy=0 always (root cause #2 source)
  - apps/client/src/prediction/predictor.ts:113-114 — predictTick assigns localState from step's output, propagating vx=0/vy=0 (root cause #2 propagation)
  - apps/client/src/__test__/position-dispatcher.test.ts — unit tests use integer time args (1, 2, 3, 1000, 1234), masking root cause #1

  ## Debug Session 2 — edge-collision-off-by-one.md
  ---
  status: diagnosed
  trigger: "Right-edge AND bottom-edge floor collision now stop the local player ONE PIXEL FARTHER away from the wall than pre-06.7 baseline."
  created: 2026-05-17T00:00:00Z
  updated: 2026-05-17T00:00:00Z
  ---

  ## Current Focus

  hypothesis: Client-side `isFeetBboxWalkable` walkable-grid path treats the right/bottom corner sample as INCLUSIVE (via `Math.floor`), while the legacy server-side `feetBboxIntersectsAABB` polys path uses STRICT inequality. The two paths disagree by exactly 1 px on the max-axis edges only. Pre-06.7 the server-authoritative snapshot pulled the local sprite back to the polys-path position via reconcile→replay; in 06.7 the self-player reconciler became a no-op so the client's grid-path 1-px-short stop is now what the user sees.
  test: Trace exact stop pixel for a wall at tile boundary on each path.
  expecting: Grid path stops 1 px short of polys path on right + bottom edges only.
  next_action: Return diagnosis (diagnose-only mode).

  ## Symptoms

  expected: Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline
  actual: Sprite stops 1 px farther from wall on right/bottom edges only (left/top unchanged)
  errors: None
  reproduction: Walk right or down into BNCentral floor edge collision; compare to pre-06.7 baseline
  started: Discovered during 2026-05-17 operator UAT for Phase 06.7

  ## Eliminated

  (none — first hypothesis confirmed)

  ## Evidence

  - checked: git log on packages/game-logic/src/step.ts and collision.ts since pre-06.7
    found: Plan 02 (f9b02e3) added an `if (input === undefined) { passthrough }` short-circuit. No collision math change. collision.ts and per-axis loop unchanged since Plan 06.4 (3b696c6).
    implication: Collision arithmetic itself was identical before and after 06.7 — the off-by-one is not a math change. The change in EFFECTIVE final pixel for the local sprite must come from a removed corrective path elsewhere.

  - checked: apps/client/src/prediction/reconciler.ts
    found: `onServerSnapshot(snap, isSelfPlayer = false)` returns immediately when `isSelfPlayer === true` (line 53-54). Added in Plan 03 (8ba0f9b). GameScene calls it with `true` for self-player (GameScene.ts line ~785).
    implication: Pre-06.7 the self-player path went through `applyServerSnapshot` → predictor replaced local x/y with server's authoritative coordinates, then replayed unacked inputs. Sprite was lerp/snapped to the post-replay predicted state, which started from the SERVER's collision-resolved position. Post-06.7 the self-player skips that path entirely; the local sprite stays at whatever pixel the client's own predictor reached.

  - checked: apps/client/src/scenes/GameScene.ts:currentLayoutForSim() and apps/client/src/prediction/predictor.ts:predictTick()
    found: Client predictor passes `walkable_grid: this.roomRenderer?.walkableGrid` into RoomLayout when grid is available. The room is always derived locally → grid is always present in real BNCentral runs.
    implication: Client's predictTick → step() always takes the walkable_grid branch in step.ts:179-225 (per-axis pixel loop calling isFeetBboxWalkable). Server's step() (no walkable_grid) takes the legacy branch (step.ts:226-237) using resolveCollision (feetBboxIntersectsAABB with STRICT inequality).

  - checked: Compare stop-pixel math for the two paths against a tile-aligned wall.
    Setup: walkable cell N (col), unwalkable cell N+1 to the right. tile_w = 44. NAVI_MASK.right = +9.

    Polys path (feetBboxIntersectsAABB, line 63-74 of collision.ts):
      Wall AABB minX = (N+1) * 44.
      Blocked when feetMaxX > minX, i.e. pos.x + 9 > (N+1) * 44, i.e. pos.x > N*44 + 35.
      Stop at pos.x = N*44 + 35 (last clear pixel).

    Grid path (isFeetBboxWalkable, line 159-182 of collision.ts):
      Probe right corner at px = pos.x + 9. col = Math.floor((pos.x + 9) / 44).
      Blocked when col >= N+1, i.e. pos.x + 9 >= (N+1) * 44, i.e. pos.x >= N*44 + 35.
      Per-axis loop in step.ts:187-202 advances probeX one px at a time and breaks
      on the first BLOCKED probe. So at pos.x = N*44 + 34, the next probe is N*44 + 35
      → blocked → break. Final px = N*44 + 34 (one pixel SHORT of polys stop).

    Difference: grid path stops 1 px farther from wall than polys path on the RIGHT edge.

    found: 1-pixel asymmetry on the +x edge.
    implication: Matches user symptom exactly. The asymmetry comes from `Math.floor`
    in isFeetBboxWalkable treating the right-corner pixel sample as inclusive, while
    feetBboxIntersectsAABB treats the bbox right edge as exclusive (`feetMaxX > b.minX`,
    strict). The two boundary conventions are inconsistent.

  - checked: Same comparison on BOTTOM edge. tile_h = 40, NAVI_MASK.bottom = 0.
    Polys: blocked at pos.y > (N+1)*40 → stop at pos.y = (N+1)*40 = N*40 + 40.
    Grid:  feet-bottom px = pos.y + 0. col blocked when pos.y >= (N+1)*40
           → loop breaks at probeY = (N+1)*40 → stop at pos.y = N*40 + 39.
    found: Grid path stops 1 px farther on bottom edge too.
    implication: Matches user symptom — both right AND bottom report +1 px gap.

  - checked: Same comparison on LEFT and TOP edges (symmetry check).
    Left:  NAVI_MASK.left = -9.
      Polys: blocked at pos.x < K*44 + 9 → stop at pos.x = K*44 + 9.
      Grid:  feet-left px = pos.x - 9. floor((pos.x-9)/44) must remain >= K.
             At pos.x = K*44 + 9 → floor(K*44/44) = K → walkable. At pos.x = K*44 + 8
             → floor((K*44 - 1)/44) = K-1 → blocked. Stop at pos.x = K*44 + 9.
    Top:   NAVI_MASK.top = -9. Mirror of left math — both stop at K*40 + 9.
    found: Left and top edges produce IDENTICAL stop pixels on both paths.
    implication: Explains why operator only reported right+bottom drift. Confirms
    this is not a uniform offset, it is the specific Math.floor-vs-strict-inequality
    asymmetry on the MAX edges.

  - checked: When did the asymmetry first exist? grep history of isFeetBboxWalkable.
    found: Introduced in 0c40432 (Plan 06.1-02, WALK→RUN rename + walkable-grid
    per-axis collision). The 1-px-short behavior has been present in client-side
    prediction since 06.1 but was previously masked by self-player reconciliation
    pulling the sprite to the server's polys-path position.
    implication: This is a latent bug exposed by the client-trust shift, not a new
    bug introduced by Plan 02 or 03's math. The change in OBSERVED behavior is real;
    the underlying arithmetic discrepancy predates 06.7.

  ## Resolution

  root_cause: Boundary-convention mismatch between the client-side walkable-grid collision path and the server-side polys collision path. `packages/game-logic/src/collision.ts:172` uses `Math.floor(px / grid.tile_w)` to sample the right/bottom corner of the feet AABB, treating that corner pixel as INSIDE the bbox. `packages/game-logic/src/collision.ts:69-72` (`feetBboxIntersectsAABB`) treats the bbox using STRICT inequality (`feetMaxX > b.minX`, `feetMaxY > b.minY`), where the corner pixel is OUTSIDE. The two paths produce stops that differ by exactly 1 px on the MAX axes (right, bottom) and agree on the MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path was a corrective sink — `predictor.applyServerSnapshot` replaced the client's predicted position with the server's polys-path-resolved position, then replayed unacked inputs forward — so the user saw the polys-path (flush) stop. Phase 06.7 Plan 03 changed `ReconcileEngine.onServerSnapshot` to early-return for the self-player (`apps/client/src/prediction/reconciler.ts:53-54`), removing the corrective sink. The client now displays the grid-path stop directly, which is 1 px short of pre-06.7 baseline on right and bottom edges only.

  fix: (diagnose-only — not applied)

  verification: (diagnose-only — not applied)

  files_changed: []

  ## Debug Session 3 — v3-rejection-wrong-close-code.md (false positive — context for Plan 07)
  ---
  status: diagnosed
  trigger: "Automated integration test protocol-v4-handshake.integ.test.ts v3-rejection assertion AssertionError: expected 524 to be 4400 (Phase 06.7 UAT Test 5, 2026-05-17)"
  created: 2026-05-17T03:28:00Z
  updated: 2026-05-17T03:35:00Z
  ---

  ## Current Focus

  hypothesis: Reported failure is NOT reproducible at HEAD (commit 9816275). Implementation already throws ServerError(4400) and the SDK surfaces 4400. The UAT capture is stale relative to the current codebase, OR was captured under conditions not reproduced by `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`.
  test: Run the test in isolation (5x) and as part of partial parallel suite — all 8 runs show `v3 client receives close code 4400 with PROTOCOL_VERSION_MISMATCH` PASS.
  expecting: confirm no behavioral change required; UAT entry should be marked stale or reproduction conditions clarified
  next_action: report ROOT CAUSE FOUND — no code change needed; recommend re-running the failed UAT test on the current HEAD to confirm before scheduling any fix work

  ## Symptoms

  expected: `expect(e.code).toBe(4400)` and `expect(String(e.message ?? '')).toMatch(/PROTOCOL_VERSION_MISMATCH/)` both pass for a v3 client; v3 is rejected before Better-Auth.
  actual (per UAT): AssertionError: expected 524 to be 4400 at protocol-v4-handshake.integ.test.ts:43:22 (v3 IS rejected; code is reported as 524 instead of 4400).
  actual (this debug session, HEAD 9816275): test passes — `e.code === 4400`, message matches `/PROTOCOL_VERSION_MISMATCH/`. Verified across 5 isolated runs + 1 partial parallel run (alongside auth/reconnect/boot/sigterm tests).
  errors: AssertionError: expected 524 to be 4400
  reproduction (claimed): `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts`
  started: discovered 2026-05-17 during Phase 06.7 UAT Test 5

  ## Eliminated

  - hypothesis: Server `onAuth` throws something other than `ServerError(4400, ...)` for protocol-version mismatch
    evidence: `apps/server/src/RebnoRoom.ts:394-398` explicitly does `throw new ServerError(4400, \`PROTOCOL_VERSION_MISMATCH expected=${v.expected} got=${v.got}\`)` when `validateAuthFrame` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH' }`. `validateAuthFrame` in `packages/protocol/src/intents.ts:350-364` returns that error iff `parsed.data.protocol_version !== PROTOCOL_VERSION` (currently 4).
    timestamp: 2026-05-17T03:30:00Z

  - hypothesis: Colyseus matchmaker intercepts the onAuth-thrown ServerError(4400) and rewrites the code to 524 (MATCHMAKE_EXPIRED)
    evidence: Traced the full server-side error path. `@colyseus/core` Room.cjs:760-789 catches the onAuth exception, deletes the reservation, and re-throws. Only adds a code if missing (`if (!e.code) e.code = APPLICATION_ERROR`) — 4400 is preserved. `@colyseus/ws-transport` WebSocketTransport.cjs:175-177 catches that and calls `client.error(e.code, e.message, ...)` (sends the in-band ERROR frame with code=4400 + msg), then closes the WS with `CloseCode.WITH_ERROR` (4002, not 524). The matchmaker code 524 (`ErrorCode.MATCHMAKE_EXPIRED`) is only thrown from `MatchMaker.cjs` reconnection-token / consumed-seat paths (lines 215-220, 715-720, 748), none of which apply to a fresh joinOrCreate with a stale protocol version.
    timestamp: 2026-05-17T03:31:00Z

  - hypothesis: SDK rewrites code 4400 → 524 during MatchMakeError construction
    evidence: `@colyseus/sdk/dist/colyseus.js:9641-9660` `consumeSeatReservation` registers `room.onError.once((code, message) => reject(new ServerError(code, message)))` and then `createMatchMakeRequest:9716-9722` re-throws as `new MatchMakeError(error.message, error.code)` — code is passed through unchanged. The SDK's WS message handler `colyseus.js:8878-8881` dispatches `Protocol.ERROR` frames as `onError.invoke(code, message)` with the server-sent code intact.
    timestamp: 2026-05-17T03:32:00Z

  - hypothesis: Test is flaky and only sometimes returns 524 instead of 4400
    evidence: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times back-to-back at HEAD 9816275: 3/3 tests passed in every run. Ran a partial parallel suite (protocol-v4 + auth + reconnect + boot + sigterm) — protocol-v4 v3-rejection still PASSED with `e.code === 4400` (252 ms). Concurrent test load did not surface the 524 result.
    timestamp: 2026-05-17T03:34:00Z

  ## Evidence

  - timestamp: 2026-05-17T03:28:00Z
    checked: `git rev-parse HEAD` after `git reset --hard 98162752325bb4bd5cd8d64694b4f175a5fef5fa`
    found: HEAD is `9816275 test(06.7): complete UAT - 3 passed, 2 issues, 1 skipped, 6 gaps logged` — the same commit on which the UAT failure was recorded.
    implication: We're inspecting the exact tree the UAT ran against.

  - timestamp: 2026-05-17T03:29:00Z
    checked: `apps/server/src/RebnoRoom.ts:373-401` (RebnoRoom.onAuth)
    found: Step-1 protocol validation runs FIRST and throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")` (or `c2s.auth PARSE_ERROR` for malformed frames). Step 2 dev-bypass + Step 3 Better-Auth getSession are both gated behind a successful validateAuthFrame.
    implication: Source matches the documented D-03/D-12 contract. No code defect on the server side.

  - timestamp: 2026-05-17T03:29:30Z
    checked: `packages/protocol/src/intents.ts:343-364` (`validateAuthFrame`) + `packages/protocol/src/version.ts:27` (PROTOCOL_VERSION)
    found: `validateAuthFrame({type:'auth', protocol_version: 3, session_token: 'dev-bypass'})` returns `{ ok: false, error: 'PROTOCOL_VERSION_MISMATCH', expected: 4, got: 3 }` — matching the test input.
    implication: The validator distinguishes PARSE_ERROR from PROTOCOL_VERSION_MISMATCH correctly; the `if (v.error === 'PROTOCOL_VERSION_MISMATCH')` branch will fire as intended.

  - timestamp: 2026-05-17T03:30:00Z
    checked: Colyseus 0.17 shared-types `ErrorCode` enum at `node_modules/.pnpm/@colyseus+shared-types@0.17.6/.../Protocol.d.ts`
    found: `MATCHMAKE_EXPIRED: 524` is an HTTP matchmaking error code (reconnection-token / seat-consumption races), not a WebSocket close code. WS close codes start at 1000 (`CloseCode.NORMAL_CLOSURE`) with the 4000-range reserved for Colyseus-app (CONSENTED=4000, SERVER_SHUTDOWN=4001, WITH_ERROR=4002, FAILED_TO_RECONNECT=4003, MAY_TRY_RECONNECT=4010).
    implication: A genuine 524 result would mean MatchMaker rejected the seat reservation BEFORE WS upgrade (HTTP-side reconnect or stale-seat path), not that onAuth's 4400 was rewritten.

  - timestamp: 2026-05-17T03:30:30Z
    checked: `node_modules/.pnpm/@colyseus+ws-transport@0.17.../WebSocketTransport.cjs:148-179` (onConnection error handler)
    found: When `connectClientToRoom` throws, transport calls `client.error(e.code, e.message, ...)` to send the in-band ERROR frame (preserving the server-thrown code, here 4400), then closes the WS with `CloseCode.WITH_ERROR` (4002 — not 524). The SDK reads the in-band ERROR frame before the close fires and surfaces it as `ServerError(4400, "PROTOCOL_VERSION_MISMATCH...")`.
    implication: 524 cannot reach the client from the onAuth path — it would require an HTTP-side MatchMaker exception or a reconnection-token race.

  - timestamp: 2026-05-17T03:33:00Z
    checked: Ran `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` 5 times at HEAD 9816275.
    found: All 5 runs report "Test Files 1 passed (1), Tests 3 passed (3)". Specifically the v3-rejection assertion (line 43 `expect(e.code).toBe(4400)`) passes — `e.code` IS 4400. Server logs show `validateAuthFrame` rejection path firing and the WS close happening before `D-51c onjoin_start` ever logs (auth runs BEFORE onJoin, so no onJoin telemetry for the v3 client — correct).
    implication: The reported AssertionError does not reproduce on the current HEAD. The implementation at HEAD already satisfies the test's expected contract.

  - timestamp: 2026-05-17T03:34:00Z
    checked: Partial parallel suite `protocol-v4-handshake + auth + reconnect + boot + sigterm`.
    found: protocol-v4-handshake v3-rejection still passes (`✓ ... v3 client receives close code 4400 ... 252ms`) under concurrent test load. Other failures in that run (sigterm, boot) are unrelated DB-seed errors — not in scope here.
    implication: The 524 result is not surfaced by adjacent-test concurrency either; the UAT capture is stale or was taken against a different (probably earlier) tree.

  - timestamp: 2026-05-17T03:35:00Z
    checked: `git log --oneline 1ba1fe6..9816275 -- apps/server/test/protocol-v4-handshake.integ.test.ts apps/server/src/RebnoRoom.ts packages/protocol/src/*` — Phase 06.7 commits touching this test or its dependencies.
    found: Only commit `f9b02e3 feat(06.7-02): accept client position updates` touched these files between Plan 01 (`1ba1fe6`) and HEAD (`9816275`). That commit RENAMED `protocol-v3-handshake.integ.test.ts → protocol-v4-handshake.integ.test.ts` and bumped the assertion from v2/v3 to v3/v4 — but did NOT change the assertion that `e.code === 4400`, nor the server-side ServerError(4400) call.
    implication: There is no obvious window in which the implementation diverged from the test. The most likely UAT capture conditions are (a) the test ran against an unbuilt/stale `@rebno/protocol` dist where `PROTOCOL_VERSION` was still 3 — causing the v3 frame to PASS validation, then later fail at the seat-reservation stage with 524; or (b) the test was run from a different worktree that was several commits behind HEAD.

  ## Resolution

  root_cause: Reported failure is not reproducible at HEAD 9816275. The server-side implementation in `apps/server/src/RebnoRoom.ts:394-398` correctly throws `ServerError(4400, "PROTOCOL_VERSION_MISMATCH expected=4 got=3")`, the Colyseus WS transport forwards the code 4400 via an in-band ERROR frame, and the SDK surfaces it to the test as `MatchMakeError{ code: 4400, message: "PROTOCOL_VERSION_MISMATCH expected=4 got=3" }`. All 6 test runs (5 isolated + 1 partial-parallel) PASS the `expect(e.code).toBe(4400)` assertion.

  Most likely explanation for the UAT-recorded 524 result: the UAT was executed against a stale `packages/protocol` build artifact (TypeScript dist or cached test transform) where `PROTOCOL_VERSION` was still 3. With version 3 on BOTH server and (test-loaded) `@rebno/protocol`, the v3 frame `validateAuthFrame` passes, the dev-bypass branch in onAuth proceeds, onJoin runs — and a subsequent step in the joinOrCreate → consumeSeatReservation flow rejects with `ErrorCode.MATCHMAKE_EXPIRED` (524). This is consistent with the SUMMARY note that the v4-acceptance case also fails with "seat reservation expired" — the same 524 surface, same Colyseus-matchmaking infra issue that Plan 02 SUMMARY already flagged for `test/reconnect.integ.test.ts`.

  Secondary candidate (lower-probability): the test runner in the UAT was sharing a `node_modules` cache with a worktree at a lower commit where the assertion order was different. The renamed test file (`protocol-v3-handshake.integ.test.ts` → `protocol-v4-handshake.integ.test.ts`) could have caused the runner to load a stale prebuilt copy that asserted against a different code.

  fix: No code change required. Recommended actions for the UAT loop:
    1. From a clean working tree at `9816275`, run `pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` and confirm the 3 tests pass.
    2. If the failure DOES reproduce, capture the actual SDK error object via `console.log({ code: e.code, message: e.message, name: e.name, raw: e })` inside the test's `catch (err)` block on a one-off branch and re-run. The presence of `name: 'MatchMakeError'` + `code: 524` + a message that does NOT contain `PROTOCOL_VERSION_MISMATCH` would confirm the stale-protocol-build hypothesis. The presence of `code: 524` + a message that DOES contain `PROTOCOL_VERSION_MISMATCH` would indicate a new SDK code-rewrite path that did not appear in this investigation.
    3. The UAT entry `06.7-UAT.md:105-114` ("severity: major") should be downgraded to `status: not_reproducible` pending step 1.

  verification: Run `pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` → 3 tests pass, including v3-rejection asserting `e.code === 4400`. Verified 6× at HEAD `9816275` in this session. The implementation in `apps/server/src/RebnoRoom.ts:373-401` is correct against the documented D-03/D-12 contract; no source change is required.

  files_changed: []

  ## Plan 05 (two-player remote sync — 3 root causes)
  ---
  phase: 06.7
  plan: 05
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/render/PlayerRenderer.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
    - REQ-SRV-03
  tags: [client, prediction, dispatch, gap-closure, remote-sync]
  must_haves:
    truths:
      - "Every position_update emitted by PositionDispatcher carries an INTEGER monotonic_at_ms — fractional Phaser rAF timestamps never reach the wire, so server zod parse never rejects the frame on the .int() constraint"
      - "Every position_update emitted while the user holds a movement key carries vx ≠ 0 and/or vy ≠ 0 (matching the input-intent axis vector × RUN_SPEED_PX_PER_TICK), NOT the post-step BNO-instant-set vx=0/vy=0"
      - "Every position_update emitted while the user holds NO movement key carries vx = 0 and vy = 0"
      - "Remote-player rendering plays the running animation when the broadcast PlayerState.anim_state.running bit is set, regardless of broadcast vx/vy values (single source of truth on the wire)"
      - "After the three fixes, the 2nd joiner sees the 1st joiner's sprite move, animate, and update at the full sim-tick cadence; the 1st joiner sees the 2nd joiner's sprite update smoothly without dropped frames"
      - "All pre-existing PositionDispatcher unit tests continue to pass (no regression on payload shape, seq monotonicity, setRoom routing, idle cadence, anim_state authoring)"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher with Math.floor(monotonic_at_ms), axis-vector callback, and vx/vy authored from input intent (not post-step PlayerSim)"
        contains: "Math.floor(monotonic_at_ms)"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Regression tests for RC #1 (fractional monotonic_at_ms floor) and RC #2 (vx/vy authored from axis vector callback)"
        contains: "1234.567"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "Wires axis-vector callback into PositionDispatcher constructor; remote sim-tick loop passes unpackAnimState(p.anim_state).running into onSimulationTickRemote"
        contains: "unpackAnimState"
      - path: "apps/client/src/render/PlayerRenderer.ts"
        provides: "onSimulationTickRemote consumes an explicit isRunning flag (from broadcast anim_state) instead of deriving it from vx/vy hypot"
        contains: "isRunning"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts:1193 (sendTick call site)"
        to: "apps/client/src/prediction/position-dispatcher.ts:sendTick monotonic_at_ms payload field"
        via: "Math.floor coercion at the dispatcher boundary"
        pattern: "Math\\.floor\\(monotonic_at_ms\\)"
      - from: "apps/client/src/scenes/GameScene.ts:676-686 (dispatcher construction)"
        to: "apps/client/src/prediction/position-dispatcher.ts sendTick vx/vy fields"
        via: "new axis-vector callback (parallel to existing getFacing / getIsRunning)"
        pattern: "getAxisVector"
      - from: "apps/client/src/scenes/GameScene.ts:1213-1226 (remote sim-tick loop)"
        to: "apps/client/src/render/PlayerRenderer.ts:onSimulationTickRemote"
        via: "new isRunning argument decoded from unpackAnimState(p.anim_state).running"
        pattern: "unpackAnimState\\(p\\.anim_state\\)"
  ---

  <objective>
  Restore the REQ-CLI-08 two-player smoke milestone: each peer sees the other's
  position, velocity, facing, and running animation update in near-real-time.

  Closes three layered root causes diagnosed in
  `.planning/debug/two-player-remote-sync-broken.md`:
    RC #1 — fractional monotonic_at_ms fails the wire schema's .int() constraint,
            dropping ~100% of position_update frames at the server boundary.
    RC #2 — PositionDispatcher reads vx/vy from PredictionEngine.getLocalState(),
            which always returns vx=0/vy=0 per the BNO instant-set model.
            Every wire frame carries vx=0, vy=0; server echoes 0; remote
            renderer derives Stand-frame from hypot(0,0)=0.
    RC #3 — Remote-render loop reads vx/vy only and ignores broadcast
            anim_state. Even when RC #2 is fixed, this is the principled
            single-source-of-truth path on the wire.

  Purpose: REQ-CLI-08 HARD MILESTONE regression. Pre-06.7 worked. Phase 06.7
  client-trust flip introduced these three latent defects.

  Output: Three atomic commits restoring two-player sync. Operator UAT Test 3
  (two-player smoke), Test 4 (was-Test-3 incidental edge collision passes
  separately via Plan 06), and Test 6 (idle cadence) all PASS post-fix.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/two-player-remote-sync-broken.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md
  @apps/client/src/prediction/position-dispatcher.ts
  @apps/client/src/__test__/position-dispatcher.test.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/render/PlayerRenderer.ts
  @packages/protocol/src/intents.ts
  @packages/protocol/src/anim-state.ts
  @packages/game-logic/src/step.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/protocol/src/intents.ts (cPositionUpdateSchema, lines 125-137):
  ```
  monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)
  vx, vy:          z.number().int().min(-32768).max(32767)
  anim_state:      z.number().int().min(0).max(255)
  ```

  From packages/protocol/src/anim-state.ts:
  ```
  export function unpackAnimState(byte: number): { facing: Direction; running: boolean }
  export function packAnimState(facing: Direction, isRunning: boolean): number
  export type Direction = 'D'|'DR'|'R'|'UR'|'U'|'UL'|'L'|'DL'
  ```

  From packages/game-logic/src/constants.ts:
  ```
  export const RUN_SPEED_PX_PER_TICK: number = 5;
  ```

  From apps/client/src/prediction/position-dispatcher.ts (current constructor):
  ```
  constructor(
    room: Pick<Room, 'send'>,
    prediction: Pick<PredictionEngine, 'getLocalState'>,
    getFacing: () => Direction,
    getIsRunning: () => boolean,
  )
  ```

  From apps/client/src/scenes/GameScene.ts:64:
  ```
  import { RUN_SPEED_PX_PER_TICK, type Direction } from '@rebno/game-logic';
  ```

  From apps/client/src/scenes/GameScene.ts:676-686 (current dispatcher construction
  — note the inline axis-vector computation that already exists for getIsRunning;
  the new axis-vector callback should use the same source):
  ```
  this.positionDispatcher = new PositionDispatcher(
    this.room,
    this.prediction,
    () => this.playerRenderer?.getLocalFacing() ?? 'D',
    () => {
      const vx = (this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK;
      const vy = (this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK;
      return Math.hypot(vx, vy) > 0;
    },
  );
  ```

  From apps/client/src/scenes/GameScene.ts:1218-1224 (current remote sim-tick loop):
  ```
  this.playerRenderer.onSimulationTickRemote(
    sessionId,
    vx,
    vy,
    p.x ?? 0,
    p.y ?? 0,
  );
  ```
  (PlayerSnapshotShape.anim_state is already declared optional at line 122.)

  From apps/client/src/render/PlayerRenderer.ts:423-470 (current
  onSimulationTickRemote derives isRunning via hypot(renderVx, renderVy) inside
  deriveFrameWithAuthoritativeFacing).
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1 (RC #1): Floor monotonic_at_ms at the dispatcher boundary + regression test</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts</files>
    <behavior>
      - RED: Add a regression test `it('RC #1 — floors fractional monotonic_at_ms to satisfy wire .int() contract', ...)` that calls `dispatcher.sendTick(1234.567)` and asserts the captured payload's `monotonic_at_ms === 1234` AND `cPositionUpdateSchema.safeParse(payload).success === true`. Run vitest — MUST fail because the current dispatcher passes 1234.567 through unmodified and zod rejects it.
      - GREEN: Change `apps/client/src/prediction/position-dispatcher.ts` line 42 from `monotonic_at_ms,` to `monotonic_at_ms: Math.floor(monotonic_at_ms),`. Update the surrounding comment to cite RC #1 from `.planning/debug/two-player-remote-sync-broken.md` and explain that Phaser's `_time` is a fractional rAF/performance.now timestamp while the wire schema requires `.int()`. Carry the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` traceable-reqs tags.
      - Re-run vitest — MUST pass.
      - Verify the existing tests (`sends a valid position_update payload with rounded local state`, `increments seq monotonically`, `setRoom routes subsequent ticks`, `uses the injected facing and running callbacks`, `D-06 idle cadence`) all still pass with their integer time args.
    </behavior>
    <action>
      Apply RC #1 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #1 option (a) — Math.floor coercion at the dispatcher boundary. Do NOT loosen the schema in `packages/protocol/src/intents.ts` (option (b) was rejected — preserves the u32 binary contract per Plan 01 codex review concern #7 fold).

      Test placement: append new `it(...)` inside the existing `describe('PositionDispatcher', ...)` block in `apps/client/src/__test__/position-dispatcher.test.ts`. Use the existing `makeMockRoom` / `makeMockPrediction` helpers and `cPositionUpdateSchema` import (already present at top of file). Tag the test with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]` to match the file's convention.

      Commit message: `fix(06.7): floor monotonic_at_ms at dispatcher boundary (RC #1)`. Body must reference `.planning/debug/two-player-remote-sync-broken.md` and explain that fractional Phaser timestamps were failing the wire schema's `.int()` constraint.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass including the new fractional-timestamp regression test. Dispatcher source contains `Math.floor(monotonic_at_ms)` at line 42 (or equivalent) with the RC #1 comment and traceable-reqs tags intact.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2 (RC #2): Author vx/vy from input-intent axis vector via injected callback</name>
    <files>apps/client/src/prediction/position-dispatcher.ts, apps/client/src/__test__/position-dispatcher.test.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Add two regression tests inside `describe('PositionDispatcher', ...)`:
          (a) `it('RC #2 — authors vx/vy from injected axis-vector callback during movement', ...)` — construct the dispatcher with a `getAxisVector` callback that returns `{ axisX: 1, axisY: -1 }`. Mock prediction returns `{ x: 100, y: 200, vx: 0, vy: 0 }` (post-step BNO instant-set). Call `sendTick(1000)`. Assert the captured payload's `vx === Math.round(1 * RUN_SPEED_PX_PER_TICK)` (= 5) and `vy === Math.round(-1 * RUN_SPEED_PX_PER_TICK)` (= -5). Assert `cPositionUpdateSchema.safeParse(payload).success === true`.
          (b) `it('RC #2 — authors vx/vy as 0 when no axis is held', ...)` — callback returns `{ axisX: 0, axisY: 0 }`. Assert payload `vx === 0` and `vy === 0`.
        Both tests MUST fail initially because the current dispatcher reads `local.vx / local.vy` (always 0).
      - GREEN: Modify `apps/client/src/prediction/position-dispatcher.ts`:
          * Add a 5th constructor parameter `getAxisVector: () => { axisX: -1 | 0 | 1; axisY: -1 | 0 | 1 }` (parallel to existing `getFacing` / `getIsRunning`).
          * Import `RUN_SPEED_PX_PER_TICK` from `@rebno/game-logic` at the top of the file.
          * Replace lines 37-38 (`vx: Math.round(local.vx), vy: Math.round(local.vy),`) with:
              `const axes = this.getAxisVector();`
              `vx: Math.round(axes.axisX * RUN_SPEED_PX_PER_TICK),`
              `vy: Math.round(axes.axisY * RUN_SPEED_PX_PER_TICK),`
          * Drop the unused `prediction` parameter ONLY IF no other call site reads `local.x` / `local.y` — currently lines 35-36 still consume `local.x` and `local.y`, so keep `prediction` and only swap the velocity reads.
          * Update the header comment to cite RC #2 and explain that vx/vy on the wire MUST represent current input INTENT (per-tick displacement vector × RUN_SPEED_PX_PER_TICK), NOT the post-step PlayerSim's BNO-instant-set vx=0. Mirror the GameScene.ts:1199-1200 pattern that already does this for `onSimulationTickLocal`.
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:676-686`:
          * Add a 5th argument to the `new PositionDispatcher(...)` call: `() => ({ axisX: (this.inputDispatcher?.axisX() ?? 0) as -1|0|1, axisY: (this.inputDispatcher?.axisY() ?? 0) as -1|0|1 })`.
          * The existing 4th callback `getIsRunning` can be left as-is OR simplified to consume the same axis source — leave as-is to minimize blast radius.
      - Update existing PositionDispatcher tests that construct the dispatcher with the old 4-arg signature: add a 5th `() => ({ axisX: 0, axisY: 0 })` arg (idle axis vector — preserves existing assertions about vx/vy from `state = { vx: 1.1, vy: -2.4 }` becoming vx/vy=0 on the wire after RC #2 — UPDATE those assertions accordingly: the old test `sends a valid position_update payload with rounded local state` asserted `vx: 1, vy: -2`; after RC #2 those become `vx: 0, vy: 0` because the wire vx/vy now ignores prediction state and reads from the axis callback). Re-state the test's intent in its description to reflect the new contract.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #2 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #2. Inject an axis-vector callback into PositionDispatcher parallel to `getFacing` / `getIsRunning`. Compute vx/vy from the axis vector × RUN_SPEED_PX_PER_TICK (mirroring GameScene.ts:1199-1200 used for `onSimulationTickLocal`). Do NOT modify `packages/game-logic/src/step.ts` — the BNO instant-set vx=0/vy=0 contract is correct for the simulation; this fix moves the wire-side velocity authoring to the dispatcher.

      Constructor signature change is a callsite update — only one production callsite (GameScene.ts:676-686) and four test constructions (apps/client/src/__test__/position-dispatcher.test.ts). All five must be updated.

      Update existing test assertions that pinned vx=1/vy=-2 on the wire — those assertions are now obsolete (wire vx/vy is sourced from axes, not prediction). New contract: wire vx/vy = round(axisX × 5), round(axisY × 5).

      Tag the new tests `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag the modified dispatcher code with the existing `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` markers.

      Commit message: `fix(06.7): author wire vx/vy from input-intent axis vector (RC #2)`. Body explains BNO instant-set returns vx=0/vy=0 from step() so the dispatcher must derive wire velocity from the input axes, mirroring the local-renderer pattern at GameScene.ts:1199-1200.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>
      All PositionDispatcher tests pass (existing + 2 new RC #2 tests + 1 new RC #1 test from Task 1). Dispatcher constructor takes 5 args including the new axis-vector callback. Wire vx/vy carries `round(axis × RUN_SPEED_PX_PER_TICK)`. GameScene constructs the dispatcher with the new callback wired to `inputDispatcher.axisX()` / `axisY()`. `pnpm -C apps/client exec tsc --noEmit` is green.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3 (RC #3): Remote-render consumes broadcast anim_state.running instead of deriving from vx/vy</name>
    <files>apps/client/src/render/PlayerRenderer.ts, apps/client/src/scenes/GameScene.ts</files>
    <behavior>
      - RED: Locate or create a PlayerRenderer.onSimulationTickRemote unit test in `apps/client/src/__test__/`. If `player-renderer.test.ts` (or similar) exists, add a test `it('RC #3 — consumes explicit isRunning flag instead of deriving from vx/vy hypot', ...)` that:
          * Sets up a remote with a known frame.
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, /* isRunning */ true)` and asserts the rendered frame key matches a Run-* sprite (e.g. starts with a Run prefix per `deriveFrameWithAuthoritativeFacing` contract).
          * Calls `onSimulationTickRemote(sid, 0, 0, x, y, false)` and asserts a Stand-* frame.
        If no test file exists, create `apps/client/src/__test__/player-renderer-remote-anim.test.ts` with minimal Phaser scene mocking sufficient to exercise the frame-key derivation path. Use the existing `firstRemoteFrame` window hook (`__rebno.firstRemoteFrame` in PlayerRenderer.ts:485) as the observable signal.
        Test MUST fail before the GREEN change because the renderer currently ignores any explicit isRunning arg.
      - GREEN: Modify `apps/client/src/render/PlayerRenderer.ts:423-470 onSimulationTickRemote`:
          * Add a new optional 6th parameter `isRunning?: boolean`.
          * When `isRunning` is provided, override the running-flag derivation: pass an explicit running override into `deriveFrameWithAuthoritativeFacing`. Two implementation options:
              (a) Add an `isRunningOverride?: boolean` arg to `deriveFrameWithAuthoritativeFacing` and short-circuit its internal hypot check when defined. Cleaner.
              (b) Pre-compute the running flag locally and call a variant. Smaller blast radius if (a) requires touching multiple deriveFrame call paths.
            Prefer (a). If `deriveFrameWithAuthoritativeFacing` lives in `apps/client/src/render/SpriteStateMachine.ts` and has unit tests, add an optional `isRunningOverride?: boolean` parameter that defaults to undefined (preserves existing behavior for callers that don't pass it).
          * Document the change with a comment citing RC #3 from `.planning/debug/two-player-remote-sync-broken.md` and Hard Rule 1 carve-out (movement = client-authoritative; anim_state byte is the single source of truth on the wire).
          * Preserve existing traceable-reqs tags.
      - Update `apps/client/src/scenes/GameScene.ts:1213-1226 remote sim-tick loop`:
          * Import `unpackAnimState` from `@rebno/protocol` (add to existing import line).
          * Inside the for loop, after reading `const vx = p.vx ?? 0; const vy = p.vy ?? 0;`, add:
              `const animByte = p.anim_state ?? 0;`
              `const isRunning = unpackAnimState(animByte).running;`
          * Pass `isRunning` as a 6th argument to `playerRenderer.onSimulationTickRemote(sessionId, vx, vy, p.x ?? 0, p.y ?? 0, isRunning)`.
          * Update the surrounding comment to cite RC #3 — broadcast anim_state.running is the canonical signal; vx/vy are kept as positional/animation inputs but the running flag now flows from the explicit byte.
      - Re-run vitest — all tests MUST pass.
    </behavior>
    <action>
      Apply RC #3 fix from `.planning/debug/two-player-remote-sync-broken.md` Resolution §Fix #3 option (a) — the principled single-source-of-truth path. Even though RC #2 alone (Task 2) would propagate non-zero vx/vy and incidentally fix the animation via the hypot derivation, RC #3 makes the anim_state byte the canonical wire signal and decouples animation correctness from velocity correctness. This survives future changes where vx/vy might be quantized, clamped, or zeroed for anti-cheat.

      If `deriveFrameWithAuthoritativeFacing` is exported from `apps/client/src/render/SpriteStateMachine.ts`, add the optional `isRunningOverride?: boolean` arg there and update existing call sites in PlayerRenderer.ts to pass `undefined` (preserves behavior). The new remote path passes the unpacked anim_state.running flag.

      PlayerSnapshotShape at GameScene.ts:122 already declares `anim_state?: number` — no shape change needed.

      Tag any new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Tag modified renderer code with `[impl->REQ-CLI-04] [impl->REQ-CLI-08]`.

      Commit message: `fix(06.7): remote-render consumes broadcast anim_state.running (RC #3)`. Body explains that the broadcast anim_state byte is the canonical wire signal per Hard Rule 1's movement carve-out; previously the remote renderer ignored it and re-derived from vx/vy hypot, masking RC #2.
    </action>
    <verify>
      <automated>pnpm -C apps/client exec vitest run</automated>
    </verify>
    <done>
      All apps/client vitest tests pass (including any new RC #3 player-renderer test). `pnpm -C apps/client exec tsc --noEmit` is green. GameScene.ts remote-sim-tick loop calls `playerRenderer.onSimulationTickRemote(sid, vx, vy, x, y, isRunning)` with `isRunning` decoded via `unpackAnimState(p.anim_state ?? 0).running`. PlayerRenderer.onSimulationTickRemote honors the explicit isRunning override when provided.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (position_update wire path) | client-authored payload; server zod-validates; carve-out: server stores and broadcasts without semantic validation per Hard Rule 1's narrow movement carve-out (anti-cheat deferred to Phase 06.8) |
  | server → client (state-sync of broadcast PlayerState) | server-authoritative broadcast of last-known position/velocity/facing/anim_state; client renderer trusts these values for remote players |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-05-01 | Tampering | wire monotonic_at_ms (after Math.floor coercion) | accept | The .int() schema constraint remains enforced server-side. Math.floor at the client dispatcher is a correctness fix for honest clients; a malicious client could still craft any integer value. Bound by .min(0).max(0xffffffff) already in the schema. |
  | T-06.7-05-02 | Tampering | wire vx/vy (now sourced from injected axis-vector callback) | accept | Wire vx/vy are still client-authored — narrow movement carve-out (Hard Rule 1) accepts this. Server stores and broadcasts. Anti-cheat (illegal-position fall trigger, speed cap, walkable-grid sanity) is deferred to Phase 06.8 per Plan 04 deferral note. |
  | T-06.7-05-03 | Tampering | broadcast anim_state.running consumed by remote renderer | accept | A malicious peer could broadcast running=true while sending vx=0/vy=0 (visual desync only — no gameplay-state effect). The anim_state byte is authored by the broadcasting client and trusted by other clients for rendering. Deferred to 06.8 anti-cheat. |
  | T-06.7-05-04 | Information Disclosure | injected axis-vector callback exposing inputDispatcher state | accept | The callback returns axisX/axisY (-1/0/1) which is already implicit in the existing wire payload (deducible from vx/vy in the OLD-Phase-pre-06.7 derivation pattern at GameScene.ts:1199-1200). No new disclosure surface. |
  </threat_model>

  <verification>
    - All three RCs fixed in three atomic commits.
    - `pnpm -C apps/client exec vitest run` — green (existing 247 tests + 4 new regression tests).
    - `pnpm -C apps/client exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (no schema change but executor MUST run this to verify nothing in @rebno/protocol export surface broke; lesson from Test 5 stale-protocol-build false positive — note recorded by Plan 07).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: re-run Test 4 (two-player smoke) — 2nd joiner sees 1st joiner's position updates, velocity-driven movement, and running animation. 1st joiner sees same of 2nd. Both should PASS.
    - Operator HUMAN-UAT Test 6 (idle cadence ~150 frames over 5s) re-checkable post-fix since RC #1 was blocking that observation.
  </verification>

  <success_criteria>
  - Three commits land on the main branch (or 06.7 branch per execute-phase worktree convention), each with a clear `fix(06.7): ... (RC #N)` message.
  - The 4 new regression tests (1 for RC #1, 2 for RC #2, 1 for RC #3) all pass.
  - All pre-existing PositionDispatcher and PlayerRenderer tests still pass (with updated assertions where the RC #2 contract change made old assertions obsolete).
  - Operator re-runs UAT Test 4 two-player smoke and reports `pass`.
  - UAT.md gap entries for the three blocker truths flip from `status: failed` to `status: resolved` (or new UAT pass log entry); STATE.md and ROADMAP.md update is the responsibility of `/gsd-verify-work 06.7` after the operator signs off.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-05-SUMMARY.md` documenting:
  - The three RCs and which commit fixed each.
  - The new constructor signature for PositionDispatcher (5 args).
  - The new optional 6th arg `isRunning` for `onSimulationTickRemote` and the underlying `isRunningOverride` on `deriveFrameWithAuthoritativeFacing`.
  - All regression tests added (file + describe name + it name).
  - The four pre-existing test assertions that needed updating to reflect the RC #2 contract change.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/two-player-remote-sync-broken.md` and the three UAT gap entries.
  </output>

  ## Plan 06 (edge collision off-by-1px)
  ---
  phase: 06.7
  plan: 06
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/game-logic/src/collision.ts
    - packages/game-logic/test/collision.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  tags: [game-logic, collision, gap-closure, edge-off-by-one]
  must_haves:
    truths:
      - "After the fix, the walkable-grid path (isFeetBboxWalkable, consumed by client-side prediction in step.ts:179-225) stops the player AT THE SAME PIXEL as the legacy polys path (feetBboxIntersectsAABB, consumed by server-side step.ts:226-237) for every wall orientation: right, bottom, left, top"
      - "Right-edge tile-boundary wall stop matches pre-06.7 baseline: pos.x = N*44 + 35 (where N is the last walkable column), NOT N*44 + 34"
      - "Bottom-edge tile-boundary wall stop matches pre-06.7 baseline: pos.y = N*40 + 40 (where N is the last walkable row), NOT N*40 + 39"
      - "Left-edge and top-edge stops remain unchanged at K*44 + 9 and K*40 + 9 respectively (no regression on the agreeing axes)"
      - "Pre-existing collision.test.ts and step.test.ts assertions continue to pass (no regression on left/top stops, axis-slide composition, or full-stop-when-fully-blocked behavior)"
    artifacts:
      - path: "packages/game-logic/src/collision.ts"
        provides: "isFeetBboxWalkable with right/bottom corner samples normalized to EXCLUSIVE bbox boundary (matches feetBboxIntersectsAABB strict-inequality convention)"
        contains: "right - 1"
      - path: "packages/game-logic/test/collision.test.ts"
        provides: "Regression tests pinning right-edge AND bottom-edge stop pixels against tile-boundary walls in walkable-grid path; left+top symmetry tests unchanged"
        contains: "edge-off-by-one"
      - path: "packages/game-logic/test/step.test.ts"
        provides: "End-to-end step() regression: a (5,0) tick into a right-edge tile-boundary wall reaches the same final pixel under grid path as under polys path"
        contains: "parity"
    key_links:
      - from: "packages/game-logic/src/step.ts:194 (probeX + NAVI_MASK.right)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable right-corner sample"
        via: "exclusive-boundary right-corner adjustment inside isFeetBboxWalkable"
        pattern: "right - 1"
      - from: "packages/game-logic/src/step.ts:215 (probeY + NAVI_MASK.bottom)"
        to: "packages/game-logic/src/collision.ts:isFeetBboxWalkable bottom-corner sample"
        via: "exclusive-boundary bottom-corner adjustment inside isFeetBboxWalkable"
        pattern: "bottom - 1"
  ---

  <objective>
  Close the 1-pixel edge-collision asymmetry exposed by Phase 06.7's self-player
  reconciler no-op (UAT.md gap 4, severity minor).

  Root cause (`.planning/debug/edge-collision-off-by-one.md`):
  Boundary-convention mismatch between two collision paths in
  `packages/game-logic/src/collision.ts`:
    - Client-side walkable-grid path (`isFeetBboxWalkable`, line 172) treats the
      right/bottom corner pixel as INCLUSIVE via `Math.floor(px / tile_w)`.
    - Server-side polys path (`feetBboxIntersectsAABB`, lines 69-72) treats the
      bbox boundary as EXCLUSIVE via strict `feetMaxX > b.minX`.
  The two paths disagree by exactly 1 px on MAX axes (right, bottom) and agree
  on MIN axes (left, top). Pre-06.7 the self-player reconcile→replay path masked
  the discrepancy by snapping the local sprite to the server's polys-path stop;
  Phase 06.7 Plan 03's `isSelfPlayer` early-return in `reconciler.ts:53-54`
  removed that corrective sink, exposing the latent grid-path 1-px-short stop.

  Purpose: restore pixel-perfect parity between client prediction and server
  authority on edge collisions. Pre-existing latent bug from Plan 06.1-02
  (`0c40432`), exposed by Phase 06.7 client-trust flip.

  Output: One atomic commit normalizing the right/bottom corner samples in
  `isFeetBboxWalkable` to be EXCLUSIVE of the bbox boundary, with regression
  tests pinning the stop pixels on all four edges. Operator UAT Test 4 edge-
  collision gap re-checks PASS.
  </objective>

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

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @CLAUDE.md
  @.planning/debug/edge-collision-off-by-one.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @packages/game-logic/src/collision.ts
  @packages/game-logic/src/step.ts
  @packages/game-logic/src/constants.ts

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From packages/game-logic/src/constants.ts:
  ```
  export const NAVI_MASK = { left: -9, right: 9, top: -9, bottom: 0 } as const;
  // tile_w = 44, tile_h = 40 (from CLAUDE.md extracted constants)
  ```

  From packages/game-logic/src/collision.ts:63-74 (feetBboxIntersectsAABB —
  the EXCLUSIVE-boundary reference convention to match):
  ```
  function feetBboxIntersectsAABB(pos: Vec2, b: AABB): boolean {
    const feetMinX = pos.x + NAVI_MASK.left;
    const feetMaxX = pos.x + NAVI_MASK.right;
    const feetMinY = pos.y + NAVI_MASK.top;
    const feetMaxY = pos.y + NAVI_MASK.bottom;
    return (
      feetMaxX > b.minX &&   // STRICT — corner at b.minX is OUTSIDE
      feetMinX < b.maxX &&
      feetMaxY > b.minY &&   // STRICT — corner at b.minY is OUTSIDE
      feetMinY < b.maxY
    );
  }
  ```

  From packages/game-logic/src/collision.ts:159-182 (current
  isFeetBboxWalkable — the path to FIX):
  ```
  export function isFeetBboxWalkable(
    grid: WalkableGrid,
    left: number,
    top: number,
    right: number,
    bottom: number,
  ): boolean {
    const xs: ReadonlyArray<number> = [left, right];
    const ys: ReadonlyArray<number> = [top, bottom];
    for (let xi = 0; xi < xs.length; xi++) {
      const px = xs[xi]!;
      const col = Math.floor(px / grid.tile_w);
      if (col < 0 || col >= grid.width_tiles) return false;
      for (let yi = 0; yi < ys.length; yi++) {
        const py = ys[yi]!;
        const row = Math.floor(py / grid.tile_h);
        if (row < 0 || row >= grid.height_tiles) return false;
        if (grid.cells[row * grid.width_tiles + col] !== 1) return false;
      }
    }
    return true;
  }
  ```

  From packages/game-logic/src/step.ts:187-202 (X-axis probe call site —
  caller passes feet-bbox right corner as `probeX + NAVI_MASK.right`):
  ```
  for (let i = 0; i < stepsX; i++) {
    const probeX = px + signX;
    if (
      isFeetBboxWalkable(
        grid,
        probeX + NAVI_MASK.left,
        py + NAVI_MASK.top,
        probeX + NAVI_MASK.right,    // right-corner pixel of feet bbox
        py + NAVI_MASK.bottom,        // bottom-corner pixel of feet bbox
      )
    ) { px = probeX; } else { break; }
  }
  ```

  Stop-pixel math (from `.planning/debug/edge-collision-off-by-one.md`):
    Wall at tile boundary: col N walkable, col N+1 unwalkable. tile_w = 44.
    - Polys path stop: pos.x = N*44 + 35 (last clear pixel; feetMaxX = 44N+44 = b.minX → not strictly greater).
    - Grid path stop (CURRENT BUG): pos.x = N*44 + 34 (one short; probe at +35 has col = floor((44N+44)/44) = N+1 → blocked).
    - Grid path stop (AFTER FIX, right-1 adjustment): pos.x = N*44 + 35 (probe at +35 has col = floor((44N+44-1)/44) = N → walkable; probe at +36 has col = floor((44N+45-1)/44) = floor((44N+44)/44) = N+1 → blocked).
    Bottom-edge math symmetric with tile_h = 40, NAVI_MASK.bottom = 0.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Normalize right/bottom corner samples in isFeetBboxWalkable to exclusive boundary + regression tests</name>
    <files>packages/game-logic/src/collision.ts, packages/game-logic/test/collision.test.ts, packages/game-logic/test/step.test.ts</files>
    <behavior>
      - RED (collision.test.ts):
          Add a `describe('isFeetBboxWalkable edge-off-by-one (Plan 06.7-06)', ...)` block with four `it(...)` tests:
          (a) `it('right-edge tile-boundary wall: bbox flush against wall (right corner at b.minX) is walkable', ...)` — construct a grid where col N is walkable, col N+1 is unwalkable. Call `isFeetBboxWalkable(grid, left=N*44-9, top=0, right=N*44+44, bottom=0)` (i.e., pos.x = N*44 + 35 → right corner = N*44 + 44 = boundary). Assert `true` (flush is OK under EXCLUSIVE convention).
          (b) `it('right-edge tile-boundary wall: bbox one px past wall (right corner at b.minX + 1) is blocked', ...)` — same grid. Call `isFeetBboxWalkable(grid, left=N*44-8, top=0, right=N*44+45, bottom=0)`. Assert `false`.
          (c) `it('bottom-edge tile-boundary wall: bbox flush (bottom corner at b.minY) is walkable', ...)` — col 0 walkable rows 0..M-1, row M unwalkable. Call `isFeetBboxWalkable(grid, left=0, top=M*40-9, right=18, bottom=M*40)`. Assert `true`.
          (d) `it('bottom-edge tile-boundary wall: bbox one px past (bottom corner at b.minY + 1) is blocked', ...)` — Assert `false`.
          Test (a) and (c) MUST fail initially because current `Math.floor(px / tile_w)` treats the boundary pixel as inside cell N+1 (or row M).
      - RED (step.test.ts):
          Add a `describe('step() edge-collision parity (Plan 06.7-06)', ...)` block with two `it(...)` tests asserting end-to-end stop-pixel parity between grid path and polys path:
          (e) `it('right-edge: grid-path final px matches polys-path final px after a (+5, 0) tick into tile-boundary wall', ...)` — build two PlayerSims at pos = (N*44 + 30, y0), tick +5 px right; one with `walkable_grid` set (grid path), one with `collision_polys` set (polys path). Assert both `final.players.get(sid).x === N*44 + 35`.
          (f) `it('bottom-edge: grid-path final py matches polys-path final py after a (0, +5) tick into tile-boundary wall', ...)` — analogous on Y axis. Assert both `final.players.get(sid).y === M*40 + 40`. NOTE: NAVI_MASK.bottom = 0, so the math is `pos.y = M*40` (not M*40+40). Confirm against the debug doc: pos.y = M*40 + 40 — the +40 comes from feetMaxY = pos.y + 0 = pos.y; blocked when pos.y > M*40; so last clear pos.y = M*40 exactly. UPDATE THE ASSERTION to `M*40` (debug doc's "N*40 + 40" where N = M-1, last walkable row, is the same pixel as M*40 where M = first unwalkable row). Document the indexing convention in a test comment to avoid future confusion.
          Both tests MUST fail initially: grid path returns one px short on right (and on bottom).
      - GREEN: Modify `packages/game-logic/src/collision.ts` `isFeetBboxWalkable` to make the right/bottom corner samples EXCLUSIVE of the bbox boundary. Implementation:
          Inside the function, BEFORE the `xs`/`ys` array construction, derive adjusted samples:
          ```
          // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
          // Right/bottom corner samples are EXCLUSIVE of the feet-bbox boundary,
          // matching feetBboxIntersectsAABB's strict-inequality convention
          // (collision.ts:69-72). Without this adjustment, the grid path stops the
          // player 1 px short on right+bottom edges vs the polys path — exposed
          // by Phase 06.7 self-player reconciler no-op. See
          // .planning/debug/edge-collision-off-by-one.md.
          const rightExclusive = right - 1;
          const bottomExclusive = bottom - 1;
          const xs: ReadonlyArray<number> = [left, rightExclusive];
          const ys: ReadonlyArray<number> = [top, bottomExclusive];
          ```
          Do NOT change the parameter signature — callers still pass `right` and `bottom` as the bbox boundary; the helper internally adjusts.
          Preserve existing traceable-reqs tags on the function.
      - Re-run vitest — all tests MUST pass.
      - Verify pre-existing tests (left/top symmetry, axis-slide, fully-blocked break) still pass.
    </behavior>
    <action>
      Apply fix from `.planning/debug/edge-collision-off-by-one.md` Resolution §root_cause via the in-helper adjustment option (UAT.md gap 4 missing-item #2 "adjust inside isFeetBboxWalkable for right/bottom only"). Chosen over the call-site adjustment in step.ts because:
        - Single chokepoint — no risk of new callers forgetting the subtract-1
        - Helper contract stays "pass the bbox boundary"; convention is encapsulated
        - Matches Plan 06.1-02 author intent (helper owns walkability semantics, not callers)

      Test placement:
        - collision.test.ts: append the new `describe(...)` block alongside existing isFeetBboxWalkable tests. Use existing `makeGrid` helper if one exists; otherwise inline a minimal `{ tile_w: 44, tile_h: 40, width_tiles, height_tiles, cells }` literal.
        - step.test.ts: append the new `describe(...)` block alongside existing step() tests. Reuse existing player/state builders.

      Tag all new tests with `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`.

      Commit message: `fix(06.7): normalize edge-collision grid path to match polys boundary convention`. Body must reference `.planning/debug/edge-collision-off-by-one.md` and explain the 1-px right/bottom asymmetry, the exclusive-boundary convention, and the self-player reconciler no-op as the trigger that exposed the latent bug.
    </action>
    <verify>
      <automated>pnpm -C packages/game-logic exec vitest run</automated>
    </verify>
    <done>
      All game-logic vitest tests pass (existing + 6 new edge-parity tests). `pnpm -C packages/game-logic exec tsc --noEmit` is green. `pnpm trace:check` is green. Right/bottom edge tile-boundary walls produce identical stop pixels under both grid path and polys path.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client prediction → server authority | Both run the SAME `step()` from `@rebno/game-logic`; after fix, both branches agree pixel-for-pixel on edge stops. Removes the 1-px self-prediction/server-authority drift that the self-player reconciler used to mask. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-06-01 | Tampering | client-side walkable-grid (`this.roomRenderer?.walkableGrid` passed into step) | accept | Already accepted in Phase 06.1-02. Client could fabricate a walkable grid permitting illegal positions; server doesn't consume the grid (server step takes polys branch, step.ts:226-237). Anti-cheat deferred to Phase 06.8 per Hard Rule 1 carve-out. This plan does not change the trust posture; it only removes a 1-px asymmetry that would otherwise produce server→client reconcile churn once anti-cheat lands. |
  | T-06.7-06-02 | Denial of Service | regression in axis-slide composition (Y advances from post-X position) | accept | Mitigated by pre-existing axis-slide tests in collision.test.ts and step.test.ts. New tests do NOT modify the loop structure, only the corner-sample math inside `isFeetBboxWalkable`. Composition contract preserved. |
  </threat_model>

  <verification>
    - `pnpm -C packages/game-logic exec vitest run` — green (existing + 6 new edge-parity tests).
    - `pnpm -C packages/game-logic exec tsc --noEmit` — green.
    - `pnpm -C packages/protocol build` — green (sanity, no schema change — Test 5 stale-build lesson).
    - `pnpm -C apps/client exec vitest run` — green (consumer of @rebno/game-logic; no regression on prediction tests).
    - `pnpm -C apps/server exec vitest run` — green (server step() takes polys branch — fix is logically idempotent for server, but verify nothing broke).
    - `pnpm trace:check` — green (existing tags preserved; new test tags `[unit->REQ-CLI-04] [unit->REQ-CLI-08]` valid).
    - Operator HUMAN-UAT post-fix: walk right and down into BNCentral floor edges; sprite stops flush against the wall (no visible 1-px gap). Compare against pre-06.7 baseline screenshot if available.
  </verification>

  <success_criteria>
  - One commit lands with message `fix(06.7): normalize edge-collision grid path to match polys boundary convention`.
  - The 6 new regression tests (4 in collision.test.ts, 2 in step.test.ts) all pass.
  - All pre-existing game-logic, apps/client, and apps/server tests still pass.
  - Operator re-checks UAT Test 4 edge-collision sub-gap and reports `pass` (stop pixels flush against right/bottom walls).
  - UAT.md gap entry for "Right-edge and bottom-edge floor collision stop position matches pre-06.7 baseline" flips from `status: failed` to `status: resolved`.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-06-SUMMARY.md` documenting:
  - The 1-px boundary-convention mismatch and which line fixed it (`isFeetBboxWalkable` right/bottom corner adjustment).
  - Why the in-helper option was chosen over the call-site option.
  - All 6 regression tests added (file + describe name + it name).
  - Verification commands run and their results.
  - Reference back to `.planning/debug/edge-collision-off-by-one.md` and the UAT.md gap entry.
  - Note that this also pre-empts future reconcile-churn once anti-cheat (Phase 06.8) re-enables the self-player snapshot path.
  </output>

  ## Plan 07 (protocol-build pre-flight DX hardening)
  ---
  phase: 06.7
  plan: 07
  type: execute
  wave: 2
  depends_on: []
  files_modified:
    - apps/server/package.json
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  autonomous: true
  gap_closure: true
  requirements:
    - REQ-SRV-14
  tags: [tooling, doctrine, gap-closure, protocol-build-preflight]
  must_haves:
    truths:
      - "`pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts` (and any other integ test that depends on @rebno/protocol's compiled dist/) automatically builds @rebno/protocol BEFORE the test run, eliminating the stale-build false positive class of failures"
      - "The HUMAN-UAT operator runbook explicitly documents the protocol-build pre-flight requirement so manual UAT operators do not re-hit Test 5's `expected: 4400 actual: 524` false positive"
      - "The pre-flight build is fast (< 5 s for a no-op rebuild) and runs unconditionally before integ tests — no mtime heuristics that could miss edits"
      - "All other pre-existing apps/server scripts (build, test, test:integration, test:full, typecheck, migrate, dev, start) continue to work; only the integ-test entry points pick up the new pretest hook"
    artifacts:
      - path: "apps/server/package.json"
        provides: "pretest:integration / pretest:full hooks that build @rebno/protocol before running integ tests"
        contains: "pnpm -C ../../packages/protocol build"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator doctrine note at the top of the runbook citing the stale-protocol-build false positive lesson and the required pre-flight command"
        contains: "stale-protocol-build"
    key_links:
      - from: "any change to packages/protocol/src/*"
        to: "apps/server/test/protocol-v4-handshake.integ.test.ts (and other consumers of @rebno/protocol's dist/)"
        via: "apps/server/package.json `pretest:integration` / `pretest:full` npm-lifecycle hook"
        pattern: "pretest:integration"
  ---

  <objective>
  Eliminate the "stale @rebno/protocol build artifact" class of false-positive
  integ-test failures by adding an automatic pre-flight `pnpm -C packages/protocol build`
  to apps/server's integ-test entry points, plus a doctrine note in the
  HUMAN-UAT runbook for operators running tests by hand.

  Source: 06.7-UAT.md Test 5 note + Resolved (false positive) section:
    "Operator process improvement: ensure pnpm -C packages/protocol build runs
     before integ tests after any protocol schema/version change."

  The Test 5 first-run failure (`expected: 4400 actual: 524` +
  'seat reservation expired') was diagnosed as a stale @rebno/protocol build
  artifact, NOT a real defect — but it cost ~20 min of operator triage time
  and was already flagged in `.planning/debug/v3-rejection-wrong-close-code.md`
  as a recurring trap. This plan automates the fix in the test runner and
  documents the doctrine for manual operators.

  Purpose: developer-experience + UAT-reliability hardening. Prevents
  recurrence of a known false-positive class in a phase where REQ-CLI-08 hard
  milestone hangs on operator UAT signoff.

  Output: One atomic commit adding `pretest:integration` + `pretest:full` npm
  lifecycle hooks to apps/server/package.json, plus a "Pre-flight" section at
  the top of 06.7-HUMAN-UAT.md.
  </objective>

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

  <context>
  @.planning/STATE.md
  @CLAUDE.md
  @.planning/debug/v3-rejection-wrong-close-code.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-UAT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
  @apps/server/package.json
  @packages/protocol/package.json

  <interfaces>
  <!-- Extracted from codebase — executor uses these directly, no exploration. -->

  From apps/server/package.json (current scripts block — line 8-17):
  ```
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -b",
    "start": "node dist/index.js",
    "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
    "test:integration": "vitest run integ.test",
    "test:full": "vitest run",
    "typecheck": "tsc --noEmit",
    "migrate": "node dist/scripts/run-migrations.js"
  }
  ```

  npm/pnpm lifecycle: a script named `preX` runs automatically before `X`.
  `pretest:integration` runs before `test:integration`; `pretest:full` runs
  before `test:full`. The plain `test` script does NOT need a hook because it
  excludes integ tests.

  Relative path from apps/server/ to packages/protocol/:
    apps/server/  →  ../../packages/protocol/
  Verified: pnpm workspace layout at repo root, see pnpm-workspace.yaml.

  From .planning/debug/v3-rejection-wrong-close-code.md (resolution steps,
  line 93 — the exact command sequence that was the missing pre-flight):
  ```
  pnpm -C packages/protocol build && pnpm -C apps/server exec vitest run test/protocol-v4-handshake.integ.test.ts
  ```
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="false">
    <name>Task 1: Add pretest:integration + pretest:full hooks to apps/server/package.json</name>
    <files>apps/server/package.json</files>
    <behavior>
      - Read current apps/server/package.json scripts block.
      - Insert two new entries directly above `test:integration`:
          `"pretest:integration": "pnpm -C ../../packages/protocol build",`
          `"pretest:full": "pnpm -C ../../packages/protocol build",`
        (the `pre*` lifecycle ensures these run automatically before
        `test:integration` and `test:full` respectively when invoked via pnpm.)
      - Leave the plain `"test"` script unchanged — it explicitly excludes
        integ tests, so a protocol rebuild adds no value and would slow down
        the inner-loop unit-test run.
      - Verify JSON is valid after edit: `pnpm -C apps/server pkg get scripts`.
      - Smoke the new pretest hook: from a state where packages/protocol/dist
        is intentionally stale (e.g., touch a source file under
        packages/protocol/src/ then run nothing), invoke
        `pnpm -C apps/server test:integration` and confirm the protocol build
        runs first.
          Acceptance: the integ-test command output shows the
          `> @rebno/protocol@... build` line BEFORE the vitest output.
          (If the executor cannot easily induce a stale-dist condition, it is
          sufficient to invoke `pnpm -C apps/server test:integration` and grep
          the output for the protocol-build line — it will run unconditionally
          thanks to `tsc -b`'s incremental check.)
    </behavior>
    <action>
      Edit apps/server/package.json via the Edit tool. Use a single `old_string`
      of the current scripts block and a `new_string` that inserts the two new
      lines in correct JSON order. Preserve trailing-comma discipline:
        ```
        "test": "vitest run --exclude 'test/**/*.integ.test.ts'",
        "pretest:integration": "pnpm -C ../../packages/protocol build",
        "test:integration": "vitest run integ.test",
        "pretest:full": "pnpm -C ../../packages/protocol build",
        "test:full": "vitest run",
        ```
      Run `node -e "JSON.parse(require('fs').readFileSync('apps/server/package.json','utf8'))"` to confirm parse.
      Run `pnpm -C apps/server test:integration` and capture the first ~30 lines of output.
    </action>
    <verify>
      <automated>cd apps/server && node -e "const s = JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts; if (!s['pretest:integration'] || !s['pretest:full']) { console.error('missing pretest hooks'); process.exit(1); } console.log('hooks OK')"</automated>
      <automated>pnpm -C apps/server test:integration 2>&1 | grep -E "^(> @rebno/protocol|PASS|FAIL)" | head -5</automated>
    </verify>
    <done>
      apps/server/package.json contains both `pretest:integration` and `pretest:full` scripts that invoke `pnpm -C ../../packages/protocol build`. Running `pnpm -C apps/server test:integration` builds @rebno/protocol first, then runs vitest. Existing scripts (test, build, dev, start, typecheck, migrate) are unchanged.
    </done>
  </task>

  <task type="auto" tdd="false">
    <name>Task 2: Add Pre-flight doctrine section to 06.7-HUMAN-UAT.md</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <behavior>
      - Read the current HUMAN-UAT runbook.
      - Insert a new `## Pre-flight (run before any test)` section directly after the YAML frontmatter and the first paragraph/heading, but BEFORE the test enumeration. Content:
          ```
          ## Pre-flight (run before any test)

          After ANY edit to `packages/protocol/src/*`, rebuild the protocol
          package before running integ tests OR manual UAT against staging:

              pnpm -C packages/protocol build

          For `apps/server` integ tests (`pnpm -C apps/server test:integration`
          or `test:full`), this build is now automatic via `pretest:integration`
          / `pretest:full` hooks (Plan 06.7-07). The command is documented here
          for manual operators running `vitest run` directly or for
          cross-package consumers (apps/client, etc.).

          **Why:** UAT Test 5 (PROTOCOL_VERSION 3 client rejection) first-run
          reported `expected: 4400 actual: 524` + 'seat reservation expired'
          failures that were diagnosed as a stale @rebno/protocol build
          artifact, NOT a real defect. After `pnpm -C packages/protocol build`,
          all 3 tests passed at HEAD `9816275`. See
          `.planning/debug/v3-rejection-wrong-close-code.md`.
          ```
      - Do NOT modify any existing test entries.
      - Add a traceable-reqs `[doc->REQ-SRV-14]` tag on a line of its own at
        the bottom of the new section (REQ-SRV-14 is the protocol-version /
        handshake req — confirm in REQUIREMENTS.md before tagging; if a more
        apt req exists for "testing doctrine", use that instead).
    </behavior>
    <action>
      Use the Edit tool with a unique anchor (the YAML frontmatter closing `---` + the first H1 line of HUMAN-UAT.md) to insert the new section without disturbing existing content. Run `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` to verify insertion.
    </action>
    <verify>
      <automated>grep -n "Pre-flight\|stale-protocol-build\|pretest:integration" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
      <automated>pnpm trace:check</automated>
    </verify>
    <done>
      06.7-HUMAN-UAT.md has a "Pre-flight (run before any test)" section between the frontmatter and the first test entry, documenting the manual protocol-build command, the automatic hook for apps/server, the "why" with reference to debug session, and a `[doc->REQ-SRV-14]` (or chosen alternative) tag. `pnpm trace:check` is green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | developer workstation → npm-lifecycle hook | The new `pretest:integration` hook runs `pnpm -C ../../packages/protocol build`, which executes `tsc -b`. tsc is already a trusted dev dep; no new attack surface. The relative path `../../packages/protocol` is resolved within the pnpm workspace and cannot escape it under normal repo structure. |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-07-01 | Tampering | malicious modification of pretest hook to inject arbitrary command | accept | apps/server/package.json is under version control; any change to scripts is visible in git diff and PR review. Same posture as every other npm script. |
  | T-06.7-07-02 | Denial of Service | pretest hook adds latency to inner-loop testing | mitigated | Hook applies only to `test:integration` and `test:full`. Plain `test` script (the inner-loop fast path that excludes integ tests) is unchanged. tsc incremental rebuild on a clean tree is ~1-2 s; acceptable for integ-test runs that already take 3+ s. |
  | T-06.7-07-03 | Tampering | doctrine note in HUMAN-UAT.md being skipped by an operator | accept | Plan does not require operators to read the doctrine — automated hook covers the apps/server path. The doctrine is a safety net for cross-package consumers and for manual `vitest` invocation. |
  </threat_model>

  <verification>
    - `pnpm -C apps/server pkg get scripts` — output includes `pretest:integration` and `pretest:full`.
    - `pnpm -C apps/server test:integration` — runs `@rebno/protocol build` first (visible in stdout), then runs vitest; all integ tests pass.
    - `grep -n "Pre-flight" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — returns a match.
    - `pnpm trace:check` — green.
    - No regression on other apps/server scripts: `pnpm -C apps/server typecheck` still green; `pnpm -C apps/server build` still green; `pnpm -C apps/server test` (unit only) still green.
  </verification>

  <success_criteria>
  - One commit lands with message `chore(06.7): auto-build @rebno/protocol before apps/server integ tests + UAT doctrine`.
  - `pnpm -C apps/server test:integration` invoked from a clean checkout produces a `@rebno/protocol build` line in its output BEFORE vitest starts.
  - 06.7-HUMAN-UAT.md has a Pre-flight section.
  - `pnpm trace:check` green.
  - Test 5 false-positive class will not recur on the apps/server integ path.
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-07-SUMMARY.md` documenting:
  - The two pretest hook entries added.
  - The HUMAN-UAT doctrine section added (with line numbers).
  - A short rationale why pretest hooks were preferred over a root-level alias or a `prepare` script.
  - Verification commands run and their results.
  - Reference back to `.planning/debug/v3-rejection-wrong-close-code.md` and the UAT.md "Resolved (false positive)" entry.
  - A note that this is a DX/UAT-reliability hardening plan — no production code or wire-protocol change.
  </output>


  ## Review Instructions

  For each plan (05, 06, 07) provide:

  1. **Summary** — One-paragraph assessment.
  2. **Strengths** — bullet points.
  3. **Concerns** — bullet points with severity HIGH/MEDIUM/LOW.
  4. **Suggestions** — specific, actionable.
  5. **Risk Assessment** — LOW/MEDIUM/HIGH overall with justification.

  Then add a **Cross-Plan Section** covering:
  - Whether the 3 plans together fully close the UAT.md gap entries (4 gaps total: 3 blocker + 1 minor; Test 5 is already a false positive resolved)
  - Whether wave parallelism is safe (Plan 05 = apps/client, Plan 06 = packages/game-logic, Plan 07 = apps/server + .planning docs — claim is "disjoint files, safe to parallelize")
  - Whether anything is missing that should be a Plan 08 (e.g., a test that pins the 1-px reconcile delta won't return when Phase 06.8 anti-cheat re-enables self-player snapshot, a guard against the inputDispatcher.axisX() type drift mentioned as a Plan 05 nit)
  - Hard Rule 1 carve-out compliance (movement client-authoritative, everything else server-authoritative)

  Be terse and concrete. No filler. Output markdown.


id: 019e38bc-ec56-7e21-96d1-7bd97fa2c4a8
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: a18c64e9ac531b1979c8158a8a0955bf186a8b31
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\17\rollout-2026-05-17T18-39-25-019e38bc-ec56-7e21-96d1-7bd97fa2c4a8.jsonl

title:
  $gsd-resume-work --- UAT notes from latest phase: UAT fail. remote player still has jerky motion in a straight line. frame analysis shows it's no longer stopping, but still gets slower
    over the course of 3 frames --> jumps far/"fast" on the 4th/1st frame. --- REGRESSION: upon reconnect, one or both sides cannot
    see/does not receive the other's movement. in this state, `facing` is seemingly the only state update that happens, and only transmits
    when the remote player stops moving on their end.

first_user_message:
  $gsd-resume-work --- UAT notes from latest phase: UAT fail. remote player still has jerky motion in a straight line. frame analysis shows it's no longer stopping, but still gets slower
    over the course of 3 frames --> jumps far/"fast" on the 4th/1st frame. --- REGRESSION: upon reconnect, one or both sides cannot
    see/does not receive the other's movement. in this state, `facing` is seemingly the only state update that happens, and only transmits
    when the remote player stops moving on their end.

preview:
  $gsd-resume-work --- UAT notes from latest phase: UAT fail. remote player still has jerky motion in a straight line. frame analysis shows it's no longer stopping, but still gets slower
    over the course of 3 frames --> jumps far/"fast" on the 4th/1st frame. --- REGRESSION: upon reconnect, one or both sides cannot
    see/does not receive the other's movement. in this state, `facing` is seemingly the only state update that happens, and only transmits
    when the remote player stops moving on their end.


id: 019e5258-7c9e-75c2-9855-02803ab56779
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-22T17-59-51-019e5258-7c9e-75c2-9855-02803ab56779.jsonl

title:
  # Cross-AI Plan Review Request — Phase 25.3

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

  ## Project Context

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

  ## What This Is

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

  ## Core Value

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

  ## Current State

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

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

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

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

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

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

  </details>

  ## Next Milestone Goals

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

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

  ## Requirements

  ### Validated

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

  ### Active

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

  ## Phase 25.3: project-context envelope persistence + encoding defects

  ### Roadmap Section

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

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

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

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

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

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

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

  ### Cross-cutting integration mandate

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

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


  ## Plans to Review

  ### 25.3-01-PLAN.md (Defect B2: project-name resolver)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

  <verification>
  After all 3 tasks complete:

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

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

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

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

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

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

  ---

  ### 25.3-02-PLAN.md (Defect D: psyche.md absorption instruction)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-03-PLAN.md (Defect C: encoding chain asymmetry)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-04-PLAN.md (Defect A: wrapper inbound routing)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

      Modify `process_file_drop`'s "commune" arm:

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

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

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

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

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

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

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

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

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

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

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

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

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

      Step 2 — `cargo build --release`.

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

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

      Step 5 — Verification (option-dependent):

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed (bullet points)
  3. **Concerns** — Potential issues, gaps, risks (bullet points with severity: HIGH/MEDIUM/LOW)
  4. **Suggestions** — Specific improvements (bullet points)
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep or over-engineering
  - Security considerations
  - Performance implications
  - Whether the plans actually achieve the phase goals

  **IMPORTANT**: For each Concern, prefix with the severity in brackets, e.g. `[HIGH]`, `[MEDIUM]`, `[LOW]`. The loop orchestrator parses these to decide replan vs converge.

  Output your review in markdown format.

first_user_message:
  # Cross-AI Plan Review Request — Phase 25.3

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

  ## Project Context

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

  ## What This Is

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

  ## Core Value

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

  ## Current State

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

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

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

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

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

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

  </details>

  ## Next Milestone Goals

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

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

  ## Requirements

  ### Validated

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

  ### Active

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

  ## Phase 25.3: project-context envelope persistence + encoding defects

  ### Roadmap Section

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

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

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

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

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

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

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

  ### Cross-cutting integration mandate

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

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


  ## Plans to Review

  ### 25.3-01-PLAN.md (Defect B2: project-name resolver)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

  <verification>
  After all 3 tasks complete:

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

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

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

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

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

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

  ---

  ### 25.3-02-PLAN.md (Defect D: psyche.md absorption instruction)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-03-PLAN.md (Defect C: encoding chain asymmetry)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-04-PLAN.md (Defect A: wrapper inbound routing)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

      Modify `process_file_drop`'s "commune" arm:

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

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

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

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

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

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

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

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

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

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

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

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

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

      Step 2 — `cargo build --release`.

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

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

      Step 5 — Verification (option-dependent):

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed (bullet points)
  3. **Concerns** — Potential issues, gaps, risks (bullet points with severity: HIGH/MEDIUM/LOW)
  4. **Suggestions** — Specific improvements (bullet points)
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep or over-engineering
  - Security considerations
  - Performance implications
  - Whether the plans actually achieve the phase goals

  **IMPORTANT**: For each Concern, prefix with the severity in brackets, e.g. `[HIGH]`, `[MEDIUM]`, `[LOW]`. The loop orchestrator parses these to decide replan vs converge.

  Output your review in markdown format.

preview:
  # Cross-AI Plan Review Request — Phase 25.3

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

  ## Project Context

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

  ## What This Is

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

  ## Core Value

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

  ## Current State

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

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

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

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

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

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

  </details>

  ## Next Milestone Goals

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

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

  ## Requirements

  ### Validated

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

  ### Active

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

  ## Phase 25.3: project-context envelope persistence + encoding defects

  ### Roadmap Section

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

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

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

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

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

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

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

  ### Cross-cutting integration mandate

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

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


  ## Plans to Review

  ### 25.3-01-PLAN.md (Defect B2: project-name resolver)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

  <verification>
  After all 3 tasks complete:

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

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

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

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

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

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

  ---

  ### 25.3-02-PLAN.md (Defect D: psyche.md absorption instruction)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-03-PLAN.md (Defect C: encoding chain asymmetry)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ### 25.3-04-PLAN.md (Defect A: wrapper inbound routing)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  </context>

  <tasks>

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

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

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

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

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

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

      Modify `process_file_drop`'s "commune" arm:

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

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

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

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

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

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

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

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

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

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

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

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

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

      Step 2 — `cargo build --release`.

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

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

      Step 5 — Verification (option-dependent):

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

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

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

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

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

  </tasks>

  <threat_model>
  ## Trust Boundaries

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

  ## STRIDE Threat Register

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

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

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

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

  ---

  ## Review Instructions

  Analyze each plan and provide:

  1. **Summary** — One-paragraph assessment
  2. **Strengths** — What's well-designed (bullet points)
  3. **Concerns** — Potential issues, gaps, risks (bullet points with severity: HIGH/MEDIUM/LOW)
  4. **Suggestions** — Specific improvements (bullet points)
  5. **Risk Assessment** — Overall risk level (LOW/MEDIUM/HIGH) with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep or over-engineering
  - Security considerations
  - Performance implications
  - Whether the plans actually achieve the phase goals

  **IMPORTANT**: For each Concern, prefix with the severity in brackets, e.g. `[HIGH]`, `[MEDIUM]`, `[LOW]`. The loop orchestrator parses these to decide replan vs converge.

  Output your review in markdown format.


