---
phase: 25.3-project-context-envelope-persistence-encoding-defects
plan: 06
subsystem: wrapper-route-g2-self-heal
tags: [defect-fix, gap-closure, g2-self-heal, respawn-dispatcher-trait, single-retry-cap, llm-mental-model]
requires:
  - 25.3-05
provides:
  - "pub(crate) trait ResumeRespawnDispatcher (D-G2-03)"
  - "impl ResumeRespawnDispatcher for WrapperState (production)"
  - "TestResumeRespawn mock (mirrors MockFireDispatcher / MockFileDropDispatcher patterns)"
  - "RouteLiveCtxOutcome::Respawned(Box<RouteLiveCtxOutcome>) variant"
  - "RouteLiveCtxOutcome::MalformedRetryExhausted variant"
  - "Extended route_live_context_md_if_changed<D: ResumeRespawnDispatcher>(&self, last_prompt: Option<&str>, dispatcher: &D) -> RouteLiveCtxOutcome"
  - "D-G2-01 trigger condition (a)+(b)+(c) gating (project: None + project resolves + prompt contains CURRENT_PROJECT_CONTEXT)"
  - "Single-retry-cap corrective-respawn flow (D-G2-04 corrective prompt literal)"
affects:
  - src/live/wrapper/mod.rs
  - src/live/wrapper/claude.rs
  - plugin/spt/.claude-plugin/plugin.json (deploy deferred to operator per project policy — see Task 4 disposition)
tech_stack:
  added: []
  patterns:
    - "Dispatcher trait test-seam pattern (third instance after FireDispatcher/Phase 29 + FileDropDispatcher/25.3-04)"
    - "Single-retry-cap via outer-helper local boolean (no allow_respawn parameter; re-entry guard handles recursion)"
    - "Boxed recursive enum variant (Respawned(Box<RouteLiveCtxOutcome>)) preserving inner-route outcome shape"
    - "Last-prompt substring detection for D-G2-01 condition (c) (greppable CURRENT_PROJECT_CONTEXT marker)"
key_files:
  created: []
  modified:
    - src/live/wrapper/mod.rs
    - src/live/wrapper/claude.rs
decisions:
  - "D-G2-01 LOCKED — G2 fires ONLY when ALL THREE conditions are true: (a) parse_two_slice returns project: None; (b) resolve_self_project_name_via_info_cwd returns Some; (c) last_prompt contains CURRENT_PROJECT_CONTEXT. Condition (c) prevents false-positive respawn on legitimate psyche.md rule-6 omissions."
  - "D-G2-02 LOCKED — Re-entry handled by Plan 25.3-05's live_ctx_route_in_flight RefCell guard. Inner respawn re-invocation early-returns SkippedReentrancyGuard. NO allow_respawn parameter on the helper signature."
  - "D-G2-03 LOCKED — Test seam via ResumeRespawnDispatcher trait + TestResumeRespawn mock. Mirrors FireDispatcher (Phase 29) + FileDropDispatcher (25.3-04) patterns."
  - "D-G2-04 LOCKED — Corrective prompt literal includes the substring 'did not honor the two-slice envelope contract' as the test-assertion anchor + references psyche.md <absorption> rule 4 + <output_envelope> rule 2."
  - "Option α LOCKED — helper receives last_prompt as an argument from the hook call site (NO new WrapperState field). Direct evidence via prompt.contains('CURRENT_PROJECT_CONTEXT'), testable without WrapperState mutation."
  - "Generic <D: ResumeRespawnDispatcher> bound chosen (NOT &dyn) — mirrors handle_fire_echo_commune_arm at line ~220. Single production caller per site; no monomorphization concern."
  - "resume_session_with_exit hook site passes `timestamped` (NOT the raw `msg` or the intermediate `prompt`) — `timestamped` is the FULL prompt the LLM saw on stdin this turn ([Current time: ...] + composed prompt + passive context). compose_passive_context embeds the CURRENT_PROJECT_CONTEXT (name=...): block when the project is resolvable — substring satisfies D-G2-01 condition (c)."
  - "init_session passes Some(&init_msg) — identity-only prompt; condition (c) unconditionally false → G2 NEVER fires on init."
  - "final_session passes None — wrapper is exiting; no follow-on LLM call; condition (c) unconditionally false → G2 NEVER fires on final_session."
  - "Deploy explicitly deferred to operator (mirrors Plan 25.3-05 Task 10 disposition; project policy per feedback_deploy_all_files.md). Plan 25.3-05's v1.11.7 has not yet been deployed either — operator runs DEPLOY.ps1 -Bump patch (v1.11.6 → v1.11.7) for Plan 25.3-05, THEN again (v1.11.7 → v1.11.8) for this plan."
metrics:
  duration: 9min
  completed: 2026-05-23
---

# Phase 25.3 Plan 06: Defect G2 Self-Heal Summary

Closes Defect G2 from Phase 25.3's defect inventory by extending Plan
25.3-05's `route_live_context_md_if_changed` HAPPY-PATH hook with a
single-retry-cap corrective-respawn flow. When the wrapper-side
post-LLM-exit re-read of `agents/{self_id}/live_context.md` detects
the file is missing its `<project-context>` envelope AND the project
IS resolvable AND the prompt sent to the LLM this turn carried a
`CURRENT_PROJECT_CONTEXT` block, the helper dispatches ONE corrective
respawn instructing the LLM to re-emit both envelopes via the Write
tool. On post-respawn success the helper re-routes (returning
`Respawned(Box<Routed{..}>)`); on retry exhaustion it logs
`[LIVE-CONTEXT-MALFORMED] retry_exhausted` and continues without
further action.

The implementation introduces the third in-house dispatcher-trait
test seam (`ResumeRespawnDispatcher`) — joining `FireDispatcher`
(Phase 29) and `FileDropDispatcher` (25.3-04) — so the full G2 fire
/ re-parse / retry-exhausted flow is covered by inline tests without
spawning a real Psyche subprocess.

Plan 25.3-05 ships as v1.11.7; this plan ships as v1.11.8 (deploy
deferred to operator per project policy — see Task 4 disposition).

## Deliverables

### Task 1 (commit f8f6b00): ResumeRespawnDispatcher trait + enum + helper signature + body extension

`src/live/wrapper/mod.rs`:

- **New trait** `pub(crate) trait ResumeRespawnDispatcher` with single
  method `resume(&self, msg: &str) -> (Option<String>, i32)`.
- **Production impl** `impl ResumeRespawnDispatcher for WrapperState`
  delegates to `self.resume_session_with_exit(msg)`.
- **Enum extension** `RouteLiveCtxOutcome` gains two new variants:
  - `Respawned(Box<RouteLiveCtxOutcome>)` — boxed to break the
    recursive enum layout; carries the second-attempt outcome.
  - `MalformedRetryExhausted` — single-retry-cap honored; helper
    logs and continues.
- **Helper signature change** from `route_live_context_md_if_changed(&self) -> RouteLiveCtxOutcome`
  to `route_live_context_md_if_changed<D: ResumeRespawnDispatcher>(&self, last_prompt: Option<&str>, dispatcher: &D) -> RouteLiveCtxOutcome`.
- **Helper body** preserves Plan 25.3-05's first 6 steps (re-entry
  guard check + Drop sentinel + ensure_agent_worktree + read +
  parse_two_slice + happy-path return) and adds steps 8-11:
  - Step 8: D-G2-01 trigger check — `project_resolves =
    resolve_self_project_name_via_info_cwd(&self.self_id).is_some()`
    + `prompt_had_project_ctx =
    last_prompt.map(|p| p.contains("CURRENT_PROJECT_CONTEXT")).unwrap_or(false)`.
    If either is false → log `[LIVE-CONTEXT-POST-WRITE] skip
    no-project-slice self_id={id} project_resolves={bool}
    prompt_had_project_ctx={bool}` and return SkippedNoProjectSlice
    (Plan 25.3-05 behavior preserved; diagnostic fields ADDED).
  - Step 9: G2 fire — log `[LIVE-CONTEXT-MALFORMED] respawning with
    corrective prompt self_id={id}`; build the D-G2-04 verbatim
    corrective prompt (substring "did not honor the two-slice
    envelope contract" is the test-assertion anchor); call
    `dispatcher.resume(&corrective)` once.
  - Step 10: re-read + re-parse. On success log
    `[LIVE-CONTEXT-POST-WRITE] route (after respawn) for {id}
    live={:?} project={:?}`, route via
    `route_two_slice_with_precedence(self_id, body2, "llm-post-write",
    short, "llm")`, return
    `Respawned(Box<Routed{project_written, live_written}>)`.
  - Step 11: retry-exhausted — log `[LIVE-CONTEXT-MALFORMED]
    retry_exhausted self_id={id}` and return MalformedRetryExhausted.
    NO second respawn.
- **TestResumeRespawn mock** added inside `file_drop_handler_tests`
  with two constructors (`new_inert(path)` records calls + writes
  nothing; `with_write_on_resume(path, body)` records calls + writes
  `body` to `path` on each call). Trait impl writes the configured
  body to the live_ctx_path before returning `(Some("ack"), 0)`.
- **Plan 25.3-05's two existing tests UPDATED** to pass
  `TestResumeRespawn::new_inert(...)`:
  - `route_live_context_md_writes_project_slice_when_present` (Test A,
    happy path) — added assertion `dispatcher.call_count() == 0`
    confirms G2 didn't fire.
  - `route_live_context_md_skips_when_reentrancy_guard_set` (Test E,
    reentrancy guard) — added same assertion confirming the early-
    return path doesn't dispatch.
  - Both tests still pass.

### Task 2 (commit 88c6e43): Three hook call sites in claude.rs

`src/live/wrapper/claude.rs`:

| Site | Line (approx) | New args | Why |
|------|---------------|----------|-----|
| `init_session` | 163 | `Some(&init_msg), self` | `init_msg` is identity-only; condition (c) false → G2 never fires here. |
| `resume_session_with_exit` | 331 | `Some(&timestamped), self` | `timestamped` = full LLM-stdin prompt this turn ([Current time: …] + composed prompt + `compose_passive_context()`). passive context embeds the `CURRENT_PROJECT_CONTEXT (name=...):` block when the project is resolvable — substring satisfies condition (c). |
| `final_session` | 530 | `None, self` | No follow-on LLM call; G2 must NOT fire on the exit path. |

**Prompt-variable choice (D-G2-01 condition c detection):** the
executor's code-reading determined the LLM saw `timestamped` (line
264 of claude.rs), NOT the intermediate `prompt` from
`compose_llm_prompt_from_envelope` (line 258). `timestamped` is what
gets written to the resume subprocess's stdin (line 302) and is the
ONLY string variable in scope at the hook site (line 331) that
includes `compose_passive_context()`'s `CURRENT_PROJECT_CONTEXT`
block. Choosing `timestamped` is therefore correct under D-G2-01.

**Generic-vs-dyn choice (Task 2 Step 5):** the executor used
generic `<D: ResumeRespawnDispatcher>` (NOT `&dyn`). Rationale:
mirrors `handle_fire_echo_commune_arm<D: FireDispatcher>` at
~line 220; single production caller per site so no
monomorphization-bloat concern. Type inference at each call site
resolves `D = WrapperState` cleanly because `WrapperState`
carries the trait impl added in Task 1.

`cargo build --release` succeeds. Both updated Plan 25.3-05 tests
still pass after the signature change.

### Task 3 (commit ada3797): Four inline G2 self-heal tests

`src/live/wrapper/mod.rs::file_drop_handler_tests`:

- **`route_live_context_md_g2_fires_when_trigger_conditions_met`** (Test a):
  malformed body + project resolves + prompt contains
  CURRENT_PROJECT_CONTEXT. Mock writes valid two-slice on resume.
  Asserts outcome is `Respawned(Box<Routed{project_written:true,..}>)`;
  `dispatcher.call_count() == 1`;
  `dispatcher.last_call().contains("did not honor the two-slice envelope contract")`
  (D-G2-04 anchor); log contains `[LIVE-CONTEXT-MALFORMED] respawning
  with corrective prompt`.
- **`route_live_context_md_g2_does_not_fire_on_rule_6_omission`** (Test b):
  malformed body + project resolves BUT prompt LACKS
  CURRENT_PROJECT_CONTEXT (simulates psyche.md rule-6 legitimate
  omission). Asserts outcome is `SkippedNoProjectSlice`;
  `dispatcher.call_count() == 0`; log contains
  `prompt_had_project_ctx=false` AND does NOT contain `respawning`.
  This is the regression guard for D-G2-01 condition (c).
- **`route_live_context_md_g2_returns_respawned_routed_when_corrective_succeeds`** (Test c):
  same trigger as (a), focuses on the strict outcome STRUCTURE —
  asserts the boxed inner `Routed { project_written: true,
  live_written: true }` (both per-slice booleans verified).
- **`route_live_context_md_g2_retry_exhausted_logs_and_continues`** (Test d):
  mock writes ANOTHER malformed body on resume → helper re-reads,
  finds project still None, returns `MalformedRetryExhausted`.
  Asserts outcome variant; `dispatcher.call_count() == 1` (single
  retry cap; NO second respawn); log contains
  `[LIVE-CONTEXT-MALFORMED] retry_exhausted self_id={id}`; test
  completes in `< 5 seconds` (infinite-loop regression guard).

All four tests use a factored `g2_setup(suffix)` helper that builds
the tempdir-rooted SPT_HOME + `agents/{id}/info.json` + git-init
project dir fixture. Each test acquires the static ENV_LOCK Mutex
to serialize SPT_HOME mutation. Each is gated on `_git_available()`
(skip if git not on PATH — matches Plan 25.3-05 Test A's gating).

### Task 4: Operator-verify checkpoint — DEFERRED to operator

Per CLAUDE.md / project memory `feedback_deploy_all_files.md` policy
and the precedent set by Plan 25.3-05's Task 10, the deploy
(`docs/DEPLOY.ps1 -Bump patch` for v1.11.7 → v1.11.8) and the UAT
re-run are operator-driven post-merge. Plan 25.3-05's v1.11.7
deploy has not yet run either (plugin.json currently at v1.11.6),
so the operator runbook for this plan is the second of two deploy
steps:

1. **First**: deploy Plan 25.3-05's v1.11.6 → v1.11.7 (HAPPY-PATH
   hook) per its Task 10 runbook.
2. **Then**: deploy this plan's v1.11.7 → v1.11.8 (G2 self-heal)
   per Plan 25.3-06's Task 4 runbook.

Plan 25.3-06's Task 4 runbook (preserved here verbatim for the
operator):

```
1. From repo root, run DEPLOY patch bump:
   powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
   Confirm: 1.11.7 -> 1.11.8 in plugin.json + Cargo.lock; cargo
   build --release succeeds; marketplace sync completes.

2. In a Claude Code session: run /reload-plugins to load spt v1.11.8.

3. Confirm Plan 25.3-05 HAPPY-PATH behavior is unchanged (regression
   smoke):
   - Send a <project-context> commune to {LIVE_ID} perch; confirm
     projects file materializes with <!-- spt:source=direct ... -->
     marker.
   - Boot a live session, let the LLM natural-cadence write
     live_context.md with a valid two-slice; confirm
     [LIVE-CONTEXT-POST-WRITE] route for {LIVE_ID} log appears and
     projects file materializes with <!-- spt:source=llm ... -->
     marker.

4. NEW — Defect G2 self-heal smoke (D-G2-01 condition c):
   - Stop the wrapper.
   - Stage a MALFORMED live_context.md manually (live slice only,
     no project slice).
   - Send a commune with <project-context> content BEFORE reviving
     (ensures the next resume's prompt carries CURRENT_PROJECT_CONTEXT
     → condition c satisfied).
   - Revive the Psyche.
   - Within ~1 minute, expect in the wrapper log:
     [LIVE-CONTEXT-MALFORMED] respawning with corrective prompt
     self_id={LIVE_ID}
   - After respawn, live_context.md contains BOTH envelopes;
     projects/{project}/{LIVE_ID}.md materializes.

5. Negative control — verify G2 does NOT fire on rule-6 omission:
   - Stage same malformed body, but do NOT pre-send a commune.
   - Revive.
   - Wait ~2 min; confirm NO [LIVE-CONTEXT-MALFORMED] respawning
     log lines; confirm [LIVE-CONTEXT-POST-WRITE] skip
     no-project-slice with prompt_had_project_ctx=false appears.

6. Verify retry-exhaustion (optional — requires hostile LLM or
   confused haiku). Inline Test (d) provides regression coverage.

Mark Defect G2 smoke + Negative control as result: pass in
25.3-HUMAN-UAT.md if both succeed.
```

## D-G2-01 Trigger Condition (c) Detection — Method Actually Used

Per the plan's `<output>` SUMMARY requirement: the executor's code
reading (Task 2 Step 2) identified the prompt variable as
`timestamped` (declared at `src/live/wrapper/claude.rs:264`):

```rust
let timestamped = format!("[Current time: {}]\n{}{}", now, prompt, passive);
```

where:
- `prompt` = `compose_llm_prompt_from_envelope(msg)` (line 258) — the
  decoded EVENT body (NO passive context yet).
- `passive` = `self.compose_passive_context()` (line 263) — embeds
  the `CURRENT_PROJECT_CONTEXT (name=...):` block when the project
  is resolvable.

Hook site (line 331) passes `Some(&timestamped), self`. Helper checks
`last_prompt.map(|p| p.contains("CURRENT_PROJECT_CONTEXT")).unwrap_or(false)`.
Match anchor is `CURRENT_PROJECT_CONTEXT` (greppable; per
`src/owl/echo_commune.rs:929` the actual emit format is
`CURRENT_PROJECT_CONTEXT (name=...):\n...`).

## Generic-vs-dyn Choice — Method Actually Used

Per the plan's `<output>` SUMMARY requirement: the executor used the
**generic `<D: ResumeRespawnDispatcher>` bound** (NOT `&dyn`),
mirroring `handle_fire_echo_commune_arm<D: FireDispatcher>` at line
~220. Single production caller per site; the cost of monomorphization
is one extra-codegen-instance of the helper per concrete dispatcher
type (`WrapperState` in production + `TestResumeRespawn` in tests).
This is the same trade Phase 29 + 25.3-04 made. No `&dyn`-vs-generic
conversion overhead needed at call sites.

## Test Pass Evidence

```
$ cargo test --release --lib live::wrapper::file_drop_handler_tests::route_live_context_md_g2 -- --test-threads=1
running 4 tests
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_does_not_fire_on_rule_6_omission ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_fires_when_trigger_conditions_met ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_retry_exhausted_logs_and_continues ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_returns_respawned_routed_when_corrective_succeeds ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 846 filtered out; finished in 2.26s

$ cargo test --release --lib live::wrapper::file_drop_handler_tests::route_live_context_md -- --test-threads=1
running 6 tests
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_fires_when_trigger_conditions_met ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_retry_exhausted_logs_and_continues ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_does_not_fire_on_rule_6_omission ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_g2_returns_respawned_routed_when_corrective_succeeds ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_skips_when_reentrancy_guard_set ... ok
test live::wrapper::file_drop_handler_tests::route_live_context_md_writes_project_slice_when_present ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 844 filtered out; finished in 3.17s

$ cargo test --release --lib live::wrapper -- --test-threads=1
test result: ok. 121 passed; 0 failed; 0 ignored; 0 measured; 729 filtered out; finished in 5.46s
(Plan 25.3-05 left this at 117 tests; +4 G2 tests = 121.)
```

`cargo build --release` succeeds at HEAD (after Task 2 commit). 6
pre-existing dead-code warnings remain; none new.

## Audits

| Audit | Expected | Actual |
|-------|----------|--------|
| `grep 'trait ResumeRespawnDispatcher' src/live/wrapper/mod.rs` | 1 | 1 |
| `grep 'impl ResumeRespawnDispatcher for WrapperState' src/live/wrapper/mod.rs` | 1 | 1 |
| `grep 'MalformedRetryExhausted' src/live/wrapper/mod.rs` | ≥ 3 | 8 (1 variant decl + 2 return sites in helper + 2 in test (d) + 3 in doc + format-string contexts) |
| `grep 'Respawned(Box<' src/live/wrapper/mod.rs` | ≥ 2 | 9 (1 variant decl + 1 return site in helper + multiple in tests + doc) |
| `grep 'route_live_context_md_if_changed' src/live/wrapper/claude.rs` | EXACTLY 3 | 3 |
| `grep 'route_live_context_md_if_changed(Some(' src/live/wrapper/claude.rs` | ≥ 2 | 2 (init_session + resume_session_with_exit) |
| `grep 'route_live_context_md_if_changed(None,' src/live/wrapper/claude.rs` | ≥ 1 | 1 (final_session) |
| D-G2-04 anchor `did not honor the two-slice envelope contract` in compiled literal (verified via standalone rustc round-trip) | 1 | 1 |

## Deviations from Plan

None. Plan 25.3-06 executed exactly as written.

The only "deviation" worth noting is the same one Plan 25.3-05 made:
the operator-verify checkpoint (Task 4 here, Task 10 there) is
explicitly deferred to operator per project policy. This is documented
in the plan itself as a `checkpoint:human-verify gate="blocking"`
task whose execution is operator-side; this executor cannot run
`DEPLOY.ps1` because that script modifies the upstream marketplace
repo and runs `claude plugin install` — both operator-domain side
effects per `feedback_deploy_all_files.md`. The runbook is preserved
verbatim above for the operator.

## Forward Reference

Plan 25.3-06 completes the Phase 25.3 defect inventory. The full
defect close-out, end-to-end:

| Defect | Surface | Plan |
|--------|---------|------|
| A | inbound commune envelope not persisted | 25.3-04 |
| B / B2 | haiku project-name resolved from cwd not Self info.json | 25.3-01 |
| C | asymmetric encoding (outer EVENT visible, inner entity-encoded) | 25.3-03 |
| D | psyche.md missing absorption rules | 25.3-02 |
| E | drop file deleted before wrapper consume (race) | 25.3-05 |
| F | LLM project-md non-emission | 25.3-05 |
| G HAPPY-PATH | LLM-written live_context.md not routed to projects/ | 25.3-05 |
| G2 self-heal | malformed live_context.md → corrective respawn | this plan (25.3-06) |
| ANSI cosmetics | wrapper-log ANSI escape leak | 25.3-05 |

Phase 25.3 = 6 plans total (25.3-01..06). v1.11.5 → v1.11.7 (deploy
pending per Plan 25.3-05) → v1.11.8 (deploy pending per this plan).

## Self-Check: PASSED

Verified post-write:

- `src/live/wrapper/mod.rs` modified — commits f8f6b00 + ada3797 FOUND.
- `src/live/wrapper/claude.rs` modified — commit 88c6e43 FOUND.
- All 3 commits present in `git log --oneline`:
  - f8f6b00 — Task 1 (trait + enum + helper signature + body extension + TestResumeRespawn mock + Plan 25.3-05 tests updated)
  - 88c6e43 — Task 2 (three claude.rs hook call sites updated)
  - ada3797 — Task 3 (four new G2 inline tests)
- All Task 1-3 inline + integration tests pass:
  - 4 new G2 tests green
  - 2 updated Plan 25.3-05 tests still green
  - Full wrapper module (121 tests) green — Plan 25.3-05's 117 + 4 new G2 tests.
- All audits pass per the table above.
- `cargo build --release` succeeds.

Task 4 deploy + UAT explicitly deferred to operator per project
policy (same disposition as Plan 25.3-05 Task 10).
