# Quick 260523-648 — Default `--period 480` + `--pulse-psyche` flag: Research

**Researched:** 2026-05-23
**Domain:** SPT live-agent pulse cadence — partial revert of `260521-oyi` + new wrapper-side gate
**Confidence:** HIGH (every claim traced to source at HEAD)

## User Constraints (from CONTEXT.md)

### Locked Decisions
- Default `--period` for `$LIVE start` / `revive` / `fork` = **480** (8 min), with `0` still accepted as explicit "no cadence wake".
- New `--pulse-psyche` flag (default `false`) on all three commands.
- When `--pulse-psyche=false`: wrapper outer loop intercepts `PULSE_TRIGGER`, skips `resume_session_checked`. Echo-gate at top of iteration still fires.
- When `--pulse-psyche=true`: legacy code path runs verbatim.
- Keep ALL sentinel-0 mechanics from `260521-oyi` (the relaxed guard, conditional argv omission, init banner branches, agents-JSON substitution).
- `pulse_psyche: bool` is persisted in `WrapperHandoffState` for handoff rehydration.
- No on-disk state migration. Existing wrapper-state.json files keep their stored `period`. (Note: today's wrapper-state.json does NOT carry `period` — see Finding 1 below.)
- CHANGELOG bumps `[1.11.8]` → `[1.11.9]`, references `[1.10.26]` as the prior entry being corrected.

### Claude's Discretion
- Test shape: at minimum CLI-parse guards for default=`Some(480)` clap default + `--pulse-psyche` parses on all three commands; wrapper-state.json round-trip if cheap.
- Whether a wrapper-level unit test asserting the skip path exists; skip if harness is expensive.
- Init-banner wording when `pulse_psyche=false` — phrasing is up to the planner.

### Deferred Ideas (OUT OF SCOPE)
- Inner-poll changes. `owl poll listen --psyche --pulse-interval N` semantics stay verbatim. PULSE_TRIGGER wire format unchanged.
- On-disk state migration for already-stored `period: 0` agents.
- Pulse-wait subcommand (`$LIVE pulse-wait`) — unrelated.

## Summary

Three deltas, all small:

1. **One-number flip × 2 sites:** `start.rs:324` and `start.rs:738` change `unwrap_or(0)` → `unwrap_or(480)`. Min-60 guard wording, `period > 0 && period < 60` shape, `--period 0` sentinel handling, conditional argv omission in `poll_psyche`, banner phrasing, agents-JSON `{{period}} seconds` substitution — **all stay verbatim** from `260521-oyi`. The sentinel-0 contract is intact; we're only moving the *default* away from the sentinel.

2. **CLI flag plumbing:** add `#[arg(long)] pulse_psyche: bool` to `LiveCommands::Start`, `Revive`, `Fork` in `src/cli.rs:254-258 / :319-323 / :349-356`. Thread it through `live::mod.rs:62 / :68 / :85` → `start::run` / `stop::run_revive` / `fork::run` (signature gains `pulse_psyche: bool`) → `WrapperState`.

3. **Wrapper outer-loop intercept + handoff persistence:**
   - Add `pub pulse_psyche: bool` to `WrapperState` (`src/live/wrapper/mod.rs:1018-1063`).
   - Add `pub pulse_psyche: bool` to `WrapperHandoffState` (`src/common/wrapper_state.rs:33-42`). Serde `#[serde(default)]` for backward compat with existing on-disk files.
   - `WrapperState::new` (`src/live/wrapper/lifecycle.rs:17`) signature gains `pulse_psyche: bool`. On handoff rehydration, prefer the field carried in the state file; otherwise use the argv-provided value.
   - `perform_wrapper_handoff` (`src/live/wrapper/mod.rs:1633`): populate `state.pulse_psyche = self.pulse_psyche` before `write_atomic`.
   - PULSE_TRIGGER intercept site is `mod.rs:1325-1333`. The skip goes BETWEEN `is_pulse_trigger` detection (1325) and `resume_session_checked` (1328).
   - The wrapper handoff argv stays `[&str; 5]` because `pulse_psyche` rides through wrapper-state.json — NOT through argv. (`run_wrapper` at `start.rs:913` still takes `(self_id, period, gen, session_name)`; the new `pulse_psyche` flows in via the lifecycle::new constructor reading the rehydrated handoff state OR the `start::run` call site adding a 5th arg + state injection.)

**Recommended primary path:** thread `pulse_psyche` through the SAME argv channel as `period` (extend `[&str; 5]` → `[&str; 6]` at three sites: `start.rs:539-545`, `start.rs:845-851`, `mod.rs:1653-1659`; add `pulse_psyche: bool` arg to `Commands::PsycheWrapper` in `cli.rs:187-193` and `run_wrapper` in `start.rs:913`). Then ALSO persist it in `WrapperHandoffState` so a handoff rehydration that loses the argv (it won't, but the state file is the source of truth post-handoff per D-07) still sees the right value. See Finding 1 / 3 below for why both channels matter.

## Focus Point Findings

### Finding 1 — WrapperState handoff state schema

**File:** `src/common/wrapper_state.rs:33-42`. Current shape:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrapperHandoffState {
    pub session_uuid: String,
    pub gen: u32,
    pub last_fresh_launch_epoch: i64,
}
```

**Period is NOT persisted today.** The prior research (260521-oyi `## Wrapper handoff` block) confirms this and it is still true at HEAD. On handoff, `period` survives because `perform_wrapper_handoff` (`mod.rs:1651`) re-emits it in argv (`let period_str = self.period.to_string();` then argv slot 2 of the `[&str; 5]`). The receiving wrapper's `run_wrapper` (`start.rs:913`) takes `period: u64` straight from argv and passes it to `WrapperState::new` (`lifecycle.rs:17`).

**Read/write call sites for wrapper-state.json:**
- WRITE (handoff): `mod.rs:1633` constructs `WrapperHandoffState`, then `wrapper_state::write_atomic(&state_file, &state)` at `mod.rs:1645`.
- WRITE (init/refresh re-publish): `lifecycle.rs:73-87` (`write_wrapper_state(&psyche_id, &published)` on handoff rehydration) and presumably in `claude::init_session` (Plan 24-04 surface — search confirmed by reference at `wrapper_state.rs:175`).
- READ (handoff rehydrate): `lifecycle.rs:29-30` (`wrapper_state_path_resolved` then `load_and_delete`).
- READ (external emitters, non-destructive): `read_wrapper_state` and `read_wrapper_state_with_retry` (`wrapper_state.rs:204` + `:284`).

**Recommended schema change:**
```rust
pub struct WrapperHandoffState {
    pub session_uuid: String,
    pub gen: u32,
    pub last_fresh_launch_epoch: i64,
    #[serde(default)]
    pub pulse_psyche: bool,
}
```
`#[serde(default)]` is critical: existing wrapper-state.json files on disk (written by the v1.11.8 binary) have no `pulse_psyche` field; without `default` they fail to parse and the handoff falls back to cold start (per `load_and_delete` semantics + D-08). With `default`, missing field deserialises to `false` — semantically correct (the operator never opted in).

**Where to read on rehydrate:** `lifecycle.rs:37-44` is the slot where the handoff state populates `(initial_session_uuid, initial_gen, initial_last_fresh_launch)`. Add a fourth tuple member for `pulse_psyche`:
```rust
let (initial_session_uuid, initial_gen, initial_last_fresh_launch, initial_pulse_psyche) =
    match &handoff_state {
        Some(s) => (s.session_uuid.clone(), s.gen, epoch_to_instant(s.last_fresh_launch_epoch), s.pulse_psyche),
        None => (String::new(), gen, Instant::now(), pulse_psyche /* argv-provided fallback */),
    };
```
The cold-start branch reads the new argv-provided `pulse_psyche` parameter; the handoff branch prefers the state file (so a mid-life flag change in argv doesn't override the explicit choice the operator made when starting the agent).

**Where to write on handoff:** `mod.rs:1633-1637`:
```rust
let state = wrapper_state::WrapperHandoffState {
    session_uuid: self.session_uuid.clone(),
    gen: self.gen,
    last_fresh_launch_epoch: lifecycle::instant_to_epoch(self.last_fresh_launch),
    pulse_psyche: self.pulse_psyche,  // NEW
};
```

**Where to write on init re-publish:** `lifecycle.rs:74-78`:
```rust
let published = wrapper_state::WrapperHandoffState {
    session_uuid: initial_session_uuid,
    gen: initial_gen,
    last_fresh_launch_epoch: instant_to_epoch(initial_last_fresh_launch),
    pulse_psyche: initial_pulse_psyche,  // NEW
};
```
Also audit `src/live/wrapper/claude.rs:init_session` for a `write_wrapper_state` call — grep confirms references at `wrapper_state.rs:175` ("Plan 24-04 calls this from init_session"). That call site MUST also be updated to include `pulse_psyche: self.pulse_psyche`. **Planner: grep `write_wrapper_state\|WrapperHandoffState\s*\{` across `src/live/wrapper/claude.rs` and update every construction site.**

### Finding 2 — PULSE_TRIGGER skip placement

**File:** `src/live/wrapper/mod.rs`. The exact code window:

```rust
// Line 1272-1274: fire_echo_commune arm (control-message; uses `continue`)
if handle_fire_echo_commune_arm(self, &msg) {
    continue;
}

// Lines 1298-1315: file_drop arm (control-message; uses `continue` or `break`)
match handle_file_drop_arm(self, &msg) {
    FileDropOutcome::Miss => { /* fall through */ }
    FileDropOutcome::Continue => { continue; }
    FileDropOutcome::BreakLoop => { /* break logic */ break; }
}

// Lines 1317-1333: PULSE_TRIGGER detection + resume
let is_pulse_trigger = msg.lines().any(|line| line.trim().starts_with("PULSE_TRIGGER"));
let response = self.resume_session_checked(&msg);
if is_pulse_trigger && response.is_none() {
    self.handle_pulse_trigger_recovery(&msg);
}
```

**Insertion site:** between `mod.rs:1325` (the `is_pulse_trigger` line) and `mod.rs:1328` (the `resume_session_checked` line). Shape:

```rust
let is_pulse_trigger = msg.lines().any(|line| line.trim().starts_with("PULSE_TRIGGER"));

// 260523-648: pulse-psyche=off gate. PULSE_TRIGGER fires the echo gate at the
// top of the NEXT outer iteration (line ~1129) but does NOT prompt the Psyche
// LLM for a resume turn. Saves a Claude turn per cadence wake when the operator
// just wants the echo-commune cadence to run.
if is_pulse_trigger && !self.pulse_psyche {
    self.log("[PULSE] pulse-psyche=off — skipping resume; echo gate fires next iteration");
    continue;
}

let response = self.resume_session_checked(&msg);
if is_pulse_trigger && response.is_none() {
    self.handle_pulse_trigger_recovery(&msg);
}
```

**Why this exact site:**
- It's AFTER the `fire_echo` arm (`mod.rs:1272`) and `file_drop` arm (`mod.rs:1298`) — those are control messages with their own dispatch; they don't reach `is_pulse_trigger` because they're matched first and `continue` away. Distinct paths.
- It's BEFORE `resume_session_checked` (`mod.rs:1328`) — the call we want to skip.
- It does NOT skip the echo gate. The echo gate is `fire_echo_commune_if_due()` at `mod.rs:1129`, which runs at the TOP of every outer iteration BEFORE `poll_psyche()`. A `continue` from line ~1326 jumps back to the top of the loop, which re-runs the orphan check (`mod.rs:1122`), `fire_echo_commune_if_due` (`mod.rs:1129`), handoff detection (`mod.rs:1135`), ready-file check (`mod.rs:1145`), 24h-refresh check (`mod.rs:1156`), then `poll_psyche` again. Echo-commune cadence is preserved.
- It does NOT skip `handle_pulse_trigger_recovery`. That helper only fires on `response.is_none()` AFTER a resume call. Skipping the resume entirely means we never hit the recovery path — which is correct: recovery exists for empty-stdout from a `claude --resume` invocation, which can't happen if we don't invoke claude.

**Side effects of `resume_session_checked` we lose by skipping** (verified via `src/live/wrapper/claude.rs:393-396`):
```rust
pub fn resume_session_checked(&self, msg: &str) -> Option<String> {
    let (resp, _exit_code) = self.resume_session_with_exit(msg);
    resp
}
```
`resume_session_checked` is `&self` (not `&mut self`) — it CANNOT mutate WrapperState fields. Inside, `resume_session_with_exit` shells out to `claude --resume`, captures stdout, returns it. Side effects:
- Writes to claude session history (a side effect of the external `claude` process).
- Parses output for markers (REPLY / SIGNOFF / FIRE_ECHO_COMMUNE_NOW / etc.) — but marker parsing happens at `mod.rs:1336-1339` (`if let Some(ref response_text) = response { ... parse_markers ... }`). NO MARKER PROCESSING fires if we skip. **This is correct behavior:** markers are emitted by the Psyche LLM in response to a prompt; if we don't prompt, there's nothing to parse.
- The 25.3-07 Plan-routed `route_live_context_md_if_changed` hook fires from inside `claude::init_session` / `resume_session_with_exit` / `final_session` (per CHANGELOG [1.11.8]). Skipping `resume_session_checked` means that hook does NOT run for the pulse path — but it ALSO doesn't write `live_context.md` (the LLM didn't run), so there's nothing to route. Symmetric.

**The next_pulse_override interaction:** `next_pulse_override: Option<u64>` is set by `fire_echo_commune_if_due` (`echo_fire.rs:133`) when the gate rejects with "sentinel fresh, schedule short pulse". It's consumed (`take()`) by `select_pulse_period` (`mod.rs:1413`) on the next `poll_psyche` entry. The skip path does not touch this field. The override remains armed and fires on the next iteration's poll_psyche call. **No interference.**

**Markers never appear in a PULSE_TRIGGER-only context** — verified: PULSE_TRIGGER messages are emitted by the inner poll (`src/owl/poll.rs:252` per 260521-oyi RESEARCH note) when the timer elapses with NO message received. The wire content is exactly the literal text `PULSE_TRIGGER` (plus optional EVENT framing — but the predicate at `mod.rs:1325` matches the raw token at start of any line). Markers like `[[REPLY:` / `[[SIGNOFF:` are emitted by the Psyche LLM in its STDOUT response. If we don't invoke claude (skip resume), no LLM stdout, no markers — nothing to lose by skipping the marker parse block at `mod.rs:1336`.

### Finding 3 — `perform_wrapper_handoff` argv re-emit

**File:** `src/live/wrapper/mod.rs:1650-1659`. Current shape:
```rust
let period_str = self.period.to_string();
let gen_str = self.gen.to_string();
let wrapper_args: [&str; 5] = [
    "_psyche-wrapper",
    &self.self_id,
    &period_str,
    &gen_str,
    &self.session_name,
];
```

**Two ways to thread `pulse_psyche` through handoff:**

**Option A (recommended): both channels.** Argv slot 6 + state-file field. Argv ensures the wrapper-state.json field acts as authoritative cache; argv guarantees behavior even when the state file is corrupted (cold-start fallback per D-08). Specifically:
- Extend the argv array: `[&str; 5]` → `[&str; 6]` at THREE sites: `start.rs:539-545` (`run` cold start), `start.rs:845-851` (`live_start_result` cold start), `mod.rs:1653-1659` (handoff re-emit). New slot value: `if self.pulse_psyche { "1" } else { "0" }` — keep it tiny, parse with `arg == "1"`.
- Extend `Commands::PsycheWrapper` (hidden subcommand, `cli.rs:187-193` per 260521-oyi RESEARCH `## CLI surface`) to accept the new positional. Its current arg list (per `start.rs:913` `pub fn run_wrapper(self_id: &str, period: u64, gen: u32, session_name: &str)`) must gain a `pulse_psyche: bool` parameter.
- `lifecycle::new` (already takes `period: u64` argv-side) takes a new `pulse_psyche: bool` argv-side. Inside, the handoff branch can override from state file; cold-start branch uses the argv value.
- `WrapperHandoffState.pulse_psyche` field (Finding 1) acts as the cached authoritative source post-handoff. On rehydration, prefer state file's value over argv (lifecycle::new logic).

**Option B (alternative): state-file only.** Don't change argv at all. `lifecycle::new` takes `pulse_psyche: bool` argv-side as the cold-start value; on handoff, the state file overrides. Simpler diff (no argv array extension, no `Commands::PsycheWrapper` change). Risk: the FIRST cold-start invocation must thread `pulse_psyche` via a different channel — but `start.rs::run` already spawns the wrapper with argv, so a 6th argv slot or env var is needed somewhere. **Cleanest is Option A.**

**Authoritative recommendation: Option A.** Symmetric with `period` (which uses argv as the wire channel for cold start AND for handoff re-emit). One channel, one source of truth. The state-file field is purely additive for handoff durability — it never needs to "win" over argv except in the handoff rehydration path where argv is just a passthrough from the parent.

### Finding 4 — CLI flag wiring

**File:** `src/cli.rs`. Current shape (verbatim):

```rust
// Lines 254-258 (LiveCommands::Start):
Start {
    id: String,
    #[arg(long)]
    period: Option<u64>,
},

// Lines 319-323 (LiveCommands::Revive):
Revive {
    id: String,
    #[arg(long)]
    period: Option<u64>,
},

// Lines 349-356 (LiveCommands::Fork):
Fork {
    src: String,
    new_id: String,
    #[arg(long)]
    period: Option<u64>,
},
```

**Each command has its OWN Args struct (no shared block).** Three independent edits.

**Recommended addition for each:**
```rust
#[arg(long)]
pulse_psyche: bool,
```
Plain `bool` (not `Option<bool>`). clap treats `bool` fields as flag-presence: absent → `false`, `--pulse-psyche` → `true`. No value argument needed (`#[arg(long)]` on `bool` is a Switch). This is the idiomatic clap derive-API pattern and matches the existing `all: bool` field on `LiveCommands::Stop` (`cli.rs:262-265`) and `forward_to_self: bool` on the Hooks subcommand (`cli.rs:242-243`).

**Dispatch wiring** at `src/live/mod.rs:62-85`:
```rust
LiveCommands::Start { id, period, pulse_psyche } => {
    start::run(&id, period, pulse_psyche);
}
// ...
LiveCommands::Revive { id, period, pulse_psyche } => {
    stop::run_revive(&id, period, pulse_psyche);
}
// ...
LiveCommands::Fork { src, new_id, period, pulse_psyche } =>
    fork::run(&src, &new_id, period, pulse_psyche),
```

**Function signature changes** (gain `pulse_psyche: bool` trailing arg):
- `src/live/start.rs:323`: `pub fn run(id: &str, period: Option<u64>, pulse_psyche: bool)`
- `src/live/start.rs:737`: `pub fn live_start_result(id: &str, period: Option<u64>, pulse_psyche: bool) -> Result<...>`
- `src/live/start.rs:913`: `pub fn run_wrapper(self_id: &str, period: u64, gen: u32, pulse_psyche: bool, session_name: &str)` — or trailing for argv ordering symmetry; see Finding 3 for argv slot placement. Cleanest is to APPEND to the argv after `session_name`: argv is `[wrapper, id, period_str, gen_str, session_name, pulse_psyche_str]`.
- `src/live/stop.rs:178`: `pub fn run_revive(id: &str, period: Option<u64>, pulse_psyche: bool)`
- `src/live/fork.rs:16`: `pub fn run(src: &str, new_id: &str, period: Option<u64>, pulse_psyche: bool)`
- `src/live/wrapper/lifecycle.rs:17`: `pub fn new(self_id: &str, period: u64, gen: u32, session_name: &str, pulse_psyche: bool) -> Self` — and threads to the new `WrapperState.pulse_psyche` field.

**External callers of `live_start_result` (MCP variant):** grep confirms it's the structured-return path. The MCP server / tool harness callers of this function (if any outside the crate) will need a parameter update. **Planner: grep `live_start_result(` across the whole repo to enumerate.**

### Finding 5 — Existing test surface

**File:** `tests/cli_parse.rs`.

**Existing tests that need touching:**

1. **`parse_live_start`** (line 339-348): destructures `LiveCommands::Start { id, period }`. Adding `pulse_psyche` to the struct breaks this destructure with E0027 ("missing field"). **Fix:** add `pulse_psyche` to the pattern, assert `assert!(!pulse_psyche)`. Mirror for `parse_live_start_period`, `parse_live_revive`, `parse_live_revive_period`.

2. **`live_start_default_period_becomes_zero_sentinel`** (line 364-379): the body asserts `assert_eq!(None::<u64>.unwrap_or(0), 0);` — a structural guard mirroring `start.rs:324 unwrap_or(0)`. This assertion is no longer aligned with the production code (which becomes `unwrap_or(480)` in this task). **Recommended fix:** rename to `live_start_default_period_is_480` and replace the body with `assert_eq!(None::<u64>.unwrap_or(480), 480);` plus a doc-comment update pointing at the new line. Keep the rationale paragraph (it's still pedagogically valuable: the structural-mirror pattern is intentional).

3. **`live_start_period_60_threads_through`** (line 382-396): asserts `Some(60)` parses. Will break on the struct destructure (same fix as #1). The assertion body is unaffected.

**New tests to add (minimum, per locked decisions):**

A. **`live_start_pulse_psyche_default_false`** — bare `live start <id>` parses to `pulse_psyche: false`.
   ```rust
   let cli = parse(&["owl", "live", "start", "doyle"]).unwrap();
   match cli.command.unwrap() {
       Commands::Live { command: LiveCommands::Start { pulse_psyche, .. } } => {
           assert!(!pulse_psyche);
       }
       other => panic!("expected Live Start, got {:?}", other),
   }
   ```

B. **`live_start_pulse_psyche_flag_parses`** — `live start <id> --pulse-psyche` parses to `pulse_psyche: true`. Mirror this test for `live revive --pulse-psyche` and `live fork src new --pulse-psyche` (three tests total, or one parameterized helper).

C. **(optional, nice-to-have)** `wrapper_state_pulse_psyche_roundtrip` in `src/common/wrapper_state.rs::tests` — write-then-read a `WrapperHandoffState` with `pulse_psyche: true` and assert it survives. Cheap; takes ~10 lines. **Recommend adding** — the existing `roundtrip` test at `wrapper_state.rs:321` already exercises the write/load_and_delete cycle; just extend the struct literal there to include `pulse_psyche: false` (which is already validated by the partial-eq compare; no extra test needed if we also add a focused test for `pulse_psyche: true`).

D. **(optional)** `wrapper_state_missing_pulse_psyche_defaults_false` — write a JSON file by hand (no `pulse_psyche` field), read with `load_and_delete`, assert `pulse_psyche == false`. Guards the `#[serde(default)]` requirement.

**Tests likely UNAFFECTED:**
- `parse_live_start_period` (line 351-360): destructure pattern will need the new field, but assertion logic is unchanged.
- `pulse_wait_too_short` golden (per 260521-oyi RESEARCH § Test Impact) — pulse-wait subcommand is independent.
- `file_drop_integration` (`tests/file_drop_integration.rs:533`) — explicit `--period 60`, no default reliance.
- `skill_hints` (`tests/skill_hints.rs:167`) — unless the SKILL.md argument-hint string changes; **planner should NOT change `argument-hint` for `--period` (still optional) but MUST add `[--pulse-psyche]` to it** if they want the picker to advertise the flag. If they do, the skill_hints test will need updating.

### Finding 6 — Pitfalls / gotchas

**P1. `handle_pulse_trigger_recovery` side effects (NOT lost when skipping).** Verified at `src/live/wrapper/claude.rs:429-464`. The helper fires init_session, retries via ccs CLI, etc. It is ONLY invoked from `mod.rs:1331-1333`, and ONLY when `is_pulse_trigger && response.is_none()` — i.e., after a Psyche resume turn returned empty stdout. If we skip the resume entirely, we cannot reach the recovery path because `response` is never bound. **This is correct.** The recovery exists to handle "claude --resume crashed / hung / returned empty for this pulse"; if we never called claude, there is nothing to recover from.

**P2. `resume_session_checked` is `&self`, NOT `&mut self`.** Verified at `claude.rs:393`. It cannot mutate session_uuid, last_fresh_launch, iteration, or any other WrapperState field. The only state-advance the skip omits is whatever `claude --resume` did externally (session history, the LLM's internal context). For a no-message PULSE_TRIGGER, that's "the LLM heard 'PULSE_TRIGGER' and chose how to respond" — purely a downstream-of-our-side effect. No upstream invariants broken.

**P3. The 24h daily refresh fires INDEPENDENTLY of `pulse_psyche`.** `mod.rs:1156-1169`: the `last_fresh_launch.elapsed() > 86400s` check runs at the top of each outer iteration, BEFORE `poll_psyche`. The skip path does not affect it. **However:** if `pulse_psyche=false` AND `period=480` (8 min), the wrapper wakes every 8 min via PULSE_TRIGGER, skips the resume, and continues. The 24h check fires on a wake that finds elapsed > 86400, then `self.init_session()` runs (fresh claude -p, no `--resume`). **This is intentional** — `init_session` is a different path from `resume_session_checked` and the 24h refresh exists to re-inject psyche.md regardless of pulse mode. Worth noting in CHANGELOG: under `pulse-psyche=off`, the Psyche LLM still re-initialises every 24h.

**P4. Marker parsing requires a response.** `mod.rs:1336-1339`: `if let Some(ref response_text) = response { for marker in parse_markers(...) { ... } }`. If `response = None` (the case after our skip — well, our skip `continue`s before `response` is even bound), the marker block is unreachable. Markers like REPLY / FIRE_ECHO_COMMUNE_NOW / etc. cannot be emitted by a PULSE_TRIGGER-only path because they require an LLM response. **Skip path drops nothing real.**

**P5. `next_pulse_override` is independent.** `select_pulse_period` (`mod.rs:1412-1414`): consumes `next_pulse_override.take()` if Some, else `self.period`. Called from `poll_psyche` (`mod.rs:1483`). The skip path `continue`s back to the loop top, runs `fire_echo_commune_if_due`, which may set `next_pulse_override = Some(short_secs)` (`echo_fire.rs:133`), and then `poll_psyche` consumes it on the next iteration. **No interference.**

**P6. CHANGELOG mention `260521-oyi` correction.** Per locked decisions, the new entry says "this corrects `[1.10.26]`". The prior entry's behavior (default `0` = no pulse) is being REVERTED with a *modification*: the default becomes `480` (8 min) AND a new flag adds the missing knob. Keep the prior `[1.10.26]` entry intact (Keep-a-Changelog convention: append, don't rewrite history).

**P7. Migration semantics for in-flight wrappers.** Per locked decisions: "No migration." If a v1.11.8 wrapper is running with `period=0` (because the operator started it under `[1.10.26]` semantics, no `--period`), and a v1.11.9 binary lands and a handoff fires:
- v1.11.8 writes wrapper-state.json with the v1.11.8 schema (no `pulse_psyche` field).
- v1.11.9 reads with `#[serde(default)]` → `pulse_psyche = false`.
- argv channel carries `period=0` (from v1.11.8's `self.period.to_string()` in `mod.rs:1651`).
- v1.11.9 wrapper starts with `period=0, pulse_psyche=false`. With sentinel-0 semantics intact (Finding §"period == 0 still means no cadence wake"), the wrapper polls forever without firing PULSE_TRIGGER on a timer. Behavior preserved exactly. **Migration-safe.**

The reverse direction (v1.11.9 → v1.11.8 downgrade) is NOT supported (deploy is monotonic), so we don't worry about a `pulse_psyche` field in a JSON file an old binary can't parse.

### Finding 7 — Version bump

**Current head:** `## [1.11.8] - 2026-05-23` (verified at `CHANGELOG.md:7`).
**Next patch:** `[1.11.9]`.

**CHANGELOG insert point:** Between the H2 / table-of-content prose at line 5 and the existing `## [1.11.8] - 2026-05-23` at line 7. Insert the new `## [1.11.9] - 2026-05-23` entry as a NEW block at line 7, pushing `[1.11.8]` down. Preserve all existing entries verbatim.

**Plugin version of record:** `plugin/spt/.claude-plugin/plugin.json` (per CLAUDE.md). The deploy script `docs/DEPLOY.ps1 -Bump patch` handles the bump automatically. **Planner: the plan should NOT manually edit `plugin.json` — DEPLOY.ps1 does it.**

**Recommended `[1.11.9]` shape (Changed + BTS split per `quick-260520-uc2` precedent):**
```markdown
## [1.11.9] - 2026-05-23

### Changed
- **`$LIVE start` / `revive` / `fork` default `--period` is now 480 seconds (8 minutes).** Corrects `[1.10.26]`, which set the default to `0` (no cadence wake). The `0`-as-no-cadence sentinel is preserved (`--period 0` still accepted), but a bare `$LIVE start <id>` now wakes the wrapper every 8 minutes — which is what fires the background echo-commune cadence (`fire_echo_commune_if_due`). A new `--pulse-psyche` flag (default `false`) gates the Psyche LLM resume turn that USED to be triggered by every PULSE_TRIGGER: when off (the default), the cadence wake fires only the echo-commune gate; when on, the legacy behavior runs (Psyche evaluates and may nudge Self). For users who relied on the old 20-minute pulse-driven Psyche evaluation, pass `--period 1200 --pulse-psyche` to restore. `--period 1..=59` still rejected. Source: quick `.planning/quick/260523-648-default-period-8m-pulse-psyche/`.

### BTS
- **`pulse_psyche: bool` field added to `WrapperState` and `WrapperHandoffState`.** Persisted in `wrapper-state.json` (with `#[serde(default)]` for backward compat with v1.11.8 state files). Threaded through argv as a new positional slot (`[&str; 5]` → `[&str; 6]` at the three wrapper-spawn call sites). Wrapper outer loop (`src/live/wrapper/mod.rs:1325`) intercepts PULSE_TRIGGER and `continue`s past `resume_session_checked` when `pulse_psyche == false`. Echo-gate at top of next iteration still fires; orphan detection, handoff detection, ready-file check, 24h refresh all unaffected. CLI surface gains `--pulse-psyche` flag on `LiveCommands::{Start, Revive, Fork}`.
```

## Files to Touch (planner reference)

| File | Edit |
|------|------|
| `src/cli.rs:254-258` | Add `pulse_psyche: bool` to `LiveCommands::Start` |
| `src/cli.rs:319-323` | Add `pulse_psyche: bool` to `LiveCommands::Revive` |
| `src/cli.rs:349-356` | Add `pulse_psyche: bool` to `LiveCommands::Fork` |
| `src/cli.rs:187-193` | Add `pulse_psyche: bool` to hidden `Commands::PsycheWrapper` (argv slot 5) |
| `src/live/mod.rs:62, :68, :85` | Destructure and pass new field |
| `src/live/start.rs:323-326` | `unwrap_or(0)` → `unwrap_or(480)`; signature gains `pulse_psyche: bool` |
| `src/live/start.rs:537-545` | argv array `[&str; 5]` → `[&str; 6]` (cold start `run`) |
| `src/live/start.rs:737-740` | `unwrap_or(0)` → `unwrap_or(480)`; signature gains `pulse_psyche: bool` |
| `src/live/start.rs:843-851` | argv array `[&str; 5]` → `[&str; 6]` (`live_start_result`) |
| `src/live/start.rs:913` | `run_wrapper` signature gains `pulse_psyche: bool` |
| `src/live/stop.rs:178` | `run_revive` signature gains `pulse_psyche: bool`; forward to `start::run` |
| `src/live/fork.rs:16` | `run` signature gains `pulse_psyche: bool`; forward to `start::run` |
| `src/live/wrapper/mod.rs:1018-1063` | `WrapperState` struct: add `pub pulse_psyche: bool` |
| `src/live/wrapper/mod.rs:1325-1333` | Insert skip block between detection and `resume_session_checked` |
| `src/live/wrapper/mod.rs:1633-1645` | Populate `state.pulse_psyche = self.pulse_psyche` |
| `src/live/wrapper/mod.rs:1650-1659` | argv array `[&str; 5]` → `[&str; 6]` (handoff re-emit) |
| `src/live/wrapper/lifecycle.rs:17` | `WrapperState::new` signature gains `pulse_psyche: bool`; threads to struct |
| `src/live/wrapper/lifecycle.rs:37-44` | Tuple destructure extension; prefer state-file value on rehydrate |
| `src/live/wrapper/lifecycle.rs:74-78` | Re-publish struct: populate `pulse_psyche` |
| `src/live/wrapper/claude.rs` | Any `WrapperHandoffState { ... }` construction site (grep for it) |
| `src/common/wrapper_state.rs:33-42` | Add `#[serde(default)] pub pulse_psyche: bool` field |
| `src/common/wrapper_state.rs:tests` | Extend existing `roundtrip` literal; add `pulse_psyche: true` round-trip test |
| `tests/cli_parse.rs:339-396` | Update destructures for new field; rename + flip `live_start_default_period_*` |
| `tests/cli_parse.rs` | Add 2-4 new tests (default false, flag parses on each command) |
| `plugin/spt/skills/live/SKILL.md` | Document new default (8 min) + `--pulse-psyche` flag |
| `plugin/spt/skills/revive/SKILL.md` | Document new default + flag |
| `CHANGELOG.md:7` | Prepend `[1.11.9]` entry |
| `psyche.md` | NO CHANGE — `{{period}} seconds` substitution handles both modes per Finding (sentinel-0 mechanics preserved) |

**Files NOT to touch** (sentinel-0 mechanics preserved verbatim from `260521-oyi`):
- `src/live/wrapper/mod.rs:1482-1502` (`poll_psyche` conditional argv) — already correct.
- `src/live/wrapper/claude.rs:16-38` (`build_agents_json`) — already correct.
- `src/live/wrapper/claude.rs:44-53` (`init_session` banner) — already correct.

## Open Questions

1. **`Commands::PsycheWrapper` argv slot ordering.** The new `pulse_psyche` argv could go before or after `session_name`. Current order is `[wrapper, id, period_str, gen_str, session_name]`. Trailing (`[..., session_name, pulse_psyche_str]`) is the lowest-blast-radius — only the LAST slot is new. Recommend trailing. Planner confirms.

2. **What format for the argv slot?** `"true"` / `"false"` vs `"1"` / `"0"` vs presence-flag `--pulse-psyche`. Since `Commands::PsycheWrapper` is a hidden subcommand with positional args (not clap flag-style on this inner subcommand per `cli.rs:187-193`), positional `"1"` / `"0"` parsing is simplest: `let pulse_psyche: bool = arg == "1";`. Recommend `"1"` / `"0"`. Planner confirms.

3. **`live_start_result` external callers.** Need a repo-wide grep (`grep -rn 'live_start_result(' src/ tests/`) to count call sites that need the new arg. Planner verifies before writing the plan.

## Sources

All claims sourced from in-repo Rust code at HEAD (2026-05-23):

- `src/cli.rs:187-193, 254-258, 319-323, 349-356` — CLI subcommand definitions
- `src/live/mod.rs:62-85` — top-level LiveCommands dispatch
- `src/live/start.rs:323-326, 537-545, 737-740, 843-851, 913-916` — start handler + wrapper spawn (post-`260521-oyi` state)
- `src/live/stop.rs:178-201` — run_revive delegation to start::run
- `src/live/fork.rs:16-26` — fork delegation
- `src/live/wrapper/mod.rs:1018-1063, 1097-1206, 1255-1333, 1412-1414, 1482-1502, 1625-1705` — WrapperState struct + outer loop + select_pulse_period + poll_psyche + perform_wrapper_handoff
- `src/live/wrapper/lifecycle.rs:8-91` — WrapperState::new + handoff rehydration
- `src/live/wrapper/claude.rs:16-53, 393-396, 429-464` — agents JSON template + init banner + resume_session_checked + handle_pulse_trigger_recovery
- `src/live/wrapper/echo_fire.rs:102-141` — fire_echo_commune_if_due, next_pulse_override interaction
- `src/common/wrapper_state.rs:33-42, 46-79, 175-208, 321-336` — WrapperHandoffState schema + atomic write + load_and_delete + write_wrapper_state + roundtrip test
- `tests/cli_parse.rs:339-396, 510-532` — existing CLI parse tests
- `CHANGELOG.md:1-13` — current head `[1.11.8]`
- `psyche.md:6` — `Pulse period: {{period}} seconds` (only token instance)
- `.planning/quick/260521-oyi-update-live-start-to-have-default-period/260521-oyi-RESEARCH.md` — prior research, sentinel-0 contract reference
- `.planning/quick/260521-oyi-update-live-start-to-have-default-period/260521-oyi-PLAN.md` — prior plan, code-touch reference
