# Quick 260521-oyi — `$LIVE start --period` default → 0 (no pulse): Research

**Researched:** 2026-05-21
**Domain:** SPT live-agent pulse cadence; CLI/wrapper contract refactor
**Confidence:** HIGH (every claim traced to source)

## Summary

`$LIVE start --period` flows from `LiveCommands::Start { period: Option<u64> }` → `live::start::run(id, period)` → `period.unwrap_or(1200)` → minimum-60 check → wrapper subprocess argv → inner `owl poll … --pulse-interval $period --once`. The inner poll fires a `PULSE_TRIGGER` and exits when the timer elapses; the wrapper then begins a Claude resume turn.

Switching the default to **"no pulse"** is **non-trivial but localized**. The blockers are:

- The minimum-60 guard would reject `0` (`if period < 60 { exit(1) }`).
- The inner poll uses `--pulse-interval` as `Option<u64>` already — `None` = no timer at all (the desired behavior). Passing `0` would NOT achieve "no pulse"; it would create a tight loop firing PULSE_TRIGGER every iteration.
- The wrapper struct field `WrapperState.period: u64` is mandatory and threaded into the Psyche `--agents` prompt template (`{{period}}`) and the init banner. A `0` value would render as "Pulse period: 0s" in the LLM prompt — semantically wrong.

**Primary recommendation:** Treat `period == 0` (or `Option<u64>::None` post-default-change) as a **sentinel meaning "no pulse"**. Skip the minimum guard for 0, plumb `Option<u64>` through to the wrapper, and have `poll_psyche` build argv WITHOUT `--pulse-interval` when period is `None`/`0`. Update the Psyche prompt template to omit/condition the pulse-period line.

## Current Contract

### CLI surface

`src/cli.rs:253-258` (LiveCommands::Start):
```rust
Start {
    id: String,
    #[arg(long)]
    period: Option<u64>,
},
```
- Type: `Option<u64>`
- Clap default: `None` (no `default_value`)
- Same shape on `LiveCommands::Revive` (cli.rs:319-323) and `LiveCommands::Fork` (cli.rs:349-356) — both also `Option<u64>`.

Hidden inner subcommand `Commands::PsycheWrapper` (cli.rs:187-193): `period: u64` — mandatory positional.

Inner poll's pulse flag `Commands::Poll.pulse_interval` (cli.rs:37-39):
```rust
/// Pulse interval in seconds. Emit PULSE_TRIGGER and exit after this many seconds with no message.
#[arg(long)]
pulse_interval: Option<u64>,
```
- Already `Option<u64>` with `None` default. `None` = no pulse timer (poll runs until message received or process exit).

**So the flag is RENAMED across the boundary:** outer = `--period`, inner = `--pulse-interval`. Same value, different names.

### Effective default today

`src/live/start.rs:182` (and `:566` in `live_start_result`):
```rust
pub fn run(id: &str, period: Option<u64>) {
    let period = period.unwrap_or(1200);
    if period < 60 {
        output::owl_err("Minimum pulse period is 60 seconds");
        std::process::exit(1);
    }
    …
```
- **Effective default: 1200 seconds (20 minutes)** when user omits `--period`.
- Minimum: 60 seconds; lower values rejected with exit(1).
- Same minimum check at `pulse_wait.rs:10` (live pulse-wait subcommand) and at `start.rs:567` (live_start_result MCP variant).

### Wrapper handoff

`src/live/start.rs:352-360` builds the wrapper argv with `period.to_string()`:
```rust
let wrapper_args: [&str; 5] = [
    "_psyche-wrapper", id, &period_str, &gen_str, &session_name,
];
```
Same shape repeated in `live_start_result` (`start.rs:641-649`) and in `WrapperState::perform_wrapper_handoff` (`wrapper/mod.rs:1099-1107`) for binary-handoff re-spawns.

The handoff state file `wrapper-state.json` does NOT persist `period` (`common/wrapper_state.rs` WrapperHandoffState: `session_uuid`, `gen`, `last_fresh_launch_epoch` only). On handoff, `period` is preserved via re-passing the literal argv from the running wrapper's `self.period` — not reconstructed from disk.

### Wrapper → inner poll

`src/live/wrapper/mod.rs:953-964` (`poll_psyche`):
```rust
let period = self.select_pulse_period();
let period_str = period.to_string();
let args: [&str; 7] = [
    "poll", &self.psyche_id, "listen",
    "--psyche",
    "--pulse-interval", &period_str,
    "--once",
];
```
`select_pulse_period()` (`wrapper/mod.rs:883-886`) returns either a consumed `next_pulse_override` (used for echo-fire micro-pulses, see `echo_fire.rs:127-128`) or the configured `self.period`.

### Psyche prompt template

`src/live/wrapper/claude.rs:16-30` (`build_agents_json`):
```rust
let prompt = PSYCHE_TEMPLATE
    .replace("{{self_id}}", self_id)
    .replace("{{psyche_id}}", psyche_id)
    .replace("{{period}}", &period.to_string());
```
Init banner (`claude.rs:37-40`):
```rust
let init_msg = format!(
    "You are now active. Begin monitoring for {}. Pulse period: {}s. Generation: {}.",
    self.self_id, self.period, self.gen
);
```
**`{{period}}` is also a substitution token in `psyche.md`** — confirm what surrounding prose says about pulse cadence.

## Behavior of `--period 0` Today

If the user runs `$LIVE start doyle --period 0`:

1. clap parses `period = Some(0)`.
2. `start::run`: `period.unwrap_or(1200)` → `0`.
3. **Hard reject:** `if period < 60` → `[31m⚠ Minimum pulse period is 60 seconds[0m` → exit(1).

`0` is currently **unreachable**. There is NO existing code path that special-cases `0` as "disable pulses".

Hypothetically, if we removed the minimum guard and let `0` flow through:
- Wrapper spawned with argv `["_psyche-wrapper", id, "0", gen, session_name]`.
- `WrapperState.period = 0` → `select_pulse_period()` returns `0`.
- Inner poll receives `--pulse-interval 0`.
- `src/owl/poll.rs:221`: `pulse_deadline = pulse_interval.map(|secs| Instant::now() + Duration::from_secs(secs))` → `Some(Instant::now() + 0s)` → already past.
- `poll.rs:337-348`: every loop iteration, `Instant::now() >= deadline` is `true` → emit `PULSE_TRIGGER` → `exit(0)`.
- Inner poll exits in microseconds with PULSE_TRIGGER. Wrapper takes that as a pulse, fires a Claude resume turn, then spawns the next inner poll — which also exits in microseconds.
- **Net behavior: pathological tight loop of Claude resume turns.** Burns API budget continuously.

So `0` cannot be the wire value for "no pulse" without additional handling. The correct wire signal is **`pulse_interval = None`** (the existing semantic), which already means "no timer, wait forever for messages".

## Wrapper Tolerance

Does the wrapper handle "I never get pulsed" gracefully? **Mostly yes, but with one caveat.**

- **Message-driven path:** Wrapper's `poll_psyche` blocks on the inner poll. Without `--pulse-interval`, inner poll runs indefinitely until a real message arrives. Wrapper resumes Claude on every received message (PULSE_TRIGGER or otherwise). So with no pulses, the wrapper still wakes on COMMUNE, file-drop EVENTs, alarms, ad-hoc owl messages — none of these depend on pulses.
- **Orphan detection** (`wrapper/orphan.rs`): runs per-iteration of the wrapper main loop, NOT per-pulse. The wrapper still detects Self going away and fires INIT_SIGNOFF. Independent of pulse cadence.
- **Echo-commune cadence** (`wrapper/echo_fire.rs`): keyed off the 15-min window + Stop-hook `.more-done` sentinel, NOT off pulse arrival. Independent.
- **24h refresh / PULSE_TRIGGER recovery tiers** (`claude.rs:333-396`): only triggered ON a PULSE_TRIGGER. With no pulses these tiers never run — fine, they exist to recover empty-stdout from a pulse-driven resume, which can't happen if no resume fires from a pulse.
- **Caveat — alarms** (`$OWL new-alarm`): listener-owned firing path writes a synthesized message into the wrapper's inbox via direct spool (Phase 18.7.1 F3). Independent of `--pulse-interval`. ✓ Still works.

**Conclusion:** Wrapper is **already tolerant** of "no pulses". No wrapper logic needs to be added — only the plumbing to NOT pass `--pulse-interval` to inner poll when no pulse is desired.

The Psyche LLM prompt is the one place where "0s pulse" would be semantically wrong text. Need to either condition the `{{period}}` substitution or change wording.

## Backward-Compat Risk

**Behavior change for existing users.** Anyone who runs `$LIVE start <id>` (no `--period`) gets 1200s pulse today, and would get NO pulse after this change.

Surfaces this matters:
1. **User mental model.** Existing skill docs (`plugin/spt/skills/live/SKILL.md:140`, `:151`, `:165`; `plugin/spt/skills/revive/SKILL.md:6`, `:17`) advertise `--period <seconds>` as optional. Documentation update needed.
2. **psyche.md prompt template.** Currently says "Pulse period: {{period}}s" (per claude.rs:38 init banner — confirm what `psyche.md` itself says about `{{period}}` token; embed via `include_str!`).
3. **No persisted user config.** Period is per-invocation; nothing on disk locks the old default. `wrapper-state.json` doesn't carry it (binary handoff re-uses the live argv). So no migration needed.
4. **Echo-commune nudges** (`wrapper/echo_fire.rs:127-128`): `next_pulse_override` is currently clamped at 60s floor "to respect the pulse-period minimum". When period becomes "no pulse", this override mechanism still works — it forces a one-time short pulse for a fresh-session boundary. Need to confirm the override still applies when period == None (i.e., the override is the ONLY pulse the wrapper ever schedules in that case).
5. **gen counter / live_context:** `gen` is incremented per `$LIVE start` regardless of period. No interaction.
6. **No live agent state assumes a pulse will fire.** Verified above (wrapper tolerance).

**Risk level: LOW for system correctness, MEDIUM for user expectation.** Single CHANGELOG entry + skill doc update closes the gap.

**Edge case:** echo-fire override behavior — when `WrapperState.period = None` (no scheduled pulse) and an echo-fire wants a `next_pulse_override = 90s`, `select_pulse_period()` must return `Some(90)` cleanly and the inner poll argv must include `--pulse-interval 90` for that ONE iteration only, then revert to no-flag. The current `select_pulse_period() -> u64` signature needs to become `Option<u64>` or a similar carrier.

## Implementation Surface

### Minimum-change path (recommended)

**Wire signal:** treat `0` as "no pulse" at the CLI boundary, convert to `Option<u64>::None` internally, plumb `Option<u64>` through to the inner poll argv (omit `--pulse-interval` flag entirely when None).

**Files + lines to touch:**

1. **`src/live/start.rs:181-186`** (`run`):
   - Change default from `1200` to `0` (or accept `None`/`Some(0)` as "no pulse").
   - Adjust minimum-60 guard: allow `0` explicitly, reject `1..=59`.
   - Plumb `Option<u64>` (or sentinel `0`) through to `wrapper_args` — currently `period.to_string()`. New shape: `period.map(|p| p.to_string()).unwrap_or_else(|| "0".to_string())` where `0` is the wire sentinel.

2. **`src/live/start.rs:565-569`** (`live_start_result`): mirror the same change. Same `unwrap_or(1200)` + minimum-60 guard.

3. **`src/live/start.rs:711-714`** (`run_wrapper`): `period: u64` stays; the wire value `0` flows in. `WrapperState::new` gets `0` and treats internally as "no pulse".

4. **`src/live/wrapper/lifecycle.rs:17-44`** (`WrapperState::new`): no signature change if using `0` sentinel. Document in field doc: "0 = no scheduled pulses".

5. **`src/live/wrapper/mod.rs:883-886`** (`select_pulse_period`):
   - Change return type from `u64` to `Option<u64>` if going the rigorous route, OR keep `u64` with `0` sentinel.
   - Override path: `next_pulse_override` stays `Option<u64>`; on consume returns the override value; otherwise returns `self.period` (0 = no pulse, >0 = pulse seconds).

6. **`src/live/wrapper/mod.rs:953-964`** (`poll_psyche`): conditional argv build.
   ```rust
   let period = self.select_pulse_period();
   let mut args: Vec<&str> = vec!["poll", &self.psyche_id, "listen", "--psyche"];
   let period_str;
   if period > 0 {
       period_str = period.to_string();
       args.push("--pulse-interval");
       args.push(&period_str);
   }
   args.push("--once");
   ```
   (Note: existing code uses `[&str; 7]` fixed array — must become `Vec<&str>` or two branches.)

7. **`src/live/wrapper/mod.rs:1099-1107`** (`perform_wrapper_handoff`): wrapper argv re-emit; `period_str` stays as `self.period.to_string()`. With `0` sentinel, no change needed beyond accepting `"0"` as a valid value.

8. **`src/live/wrapper/claude.rs:16-30`** (`build_agents_json`): when `period == 0`, the `{{period}}` substitution should produce text indicating "no scheduled pulses" or the template should be edited to conditionally omit the pulse-period line. **Requires inspecting `psyche.md` source** to see what surrounding text says about `{{period}}`.

9. **`src/live/wrapper/claude.rs:37-40`** (init banner string): conditional — "Pulse period: 0s" reads wrong. Suggest: `if self.period == 0 { "no scheduled pulses" } else { format!("{}s", self.period) }`.

10. **`src/live/stop.rs:185`** (`run_revive`): no change needed — already takes `Option<u64>` and delegates to `start::run`.

11. **`src/live/fork.rs:16`** (`run`): no change needed — same delegation pattern.

12. **`src/cli.rs:253-258`** (`LiveCommands::Start`): no clap signature change needed — `Option<u64>` already supports omission. Optionally add `default_value = "0"` to make CLI help explicit.

13. **`plugin/spt/skills/live/SKILL.md:140`, `:151`, `:165`** and **`plugin/spt/skills/revive/SKILL.md:6`, `:17`**: update prose to reflect that `--period` is optional with default "no pulses" — pulses are now opt-in.

14. **`psyche.md`** (embedded via `include_str!` at `claude.rs:13`): inspect for `{{period}}` token and surrounding prose. Likely needs conditional wording or removal of the pulse-period section.

### Out of scope for this quick task

- Pulse-wait subcommand (`src/live/pulse_wait.rs`) and its minimum-60 guard — unrelated to the start default; manual one-shot pulse.
- `$OWL new-alarm` flow (Phase 18.7) — alarms are explicit user-scheduled fires, independent of `$LIVE start --period`.

## Test Impact

Tests that assert current behavior:

| Test | Location | Impact |
|------|----------|--------|
| `parse_live_start_default_period` | `tests/cli_parse.rs:342-344` | Asserts `period == None` on bare `live start`. **Still passes** (clap parse is unchanged). |
| `parse_live_start_period` | `tests/cli_parse.rs:351-356` | Asserts `Some(600)` parse. **Still passes**. |
| `parse_live_revive_period` | `tests/cli_parse.rs:478-492` | Mirror of above for revive. **Still passes**. |
| `pulse_wait_too_short` golden | `tests/golden_live.rs:163-164` + `tests/golden/live/pulse_wait_too_short.stderr` | Asserts "Minimum pulse period is 60 seconds" on `pulse-wait 30`. **Unaffected** — pulse-wait subcommand is separate. |
| `file_drop_integration` | `tests/file_drop_integration.rs:533-538` | Spawns `live start <id> --period 60` — explicit `--period`, no default reliance. **Unaffected**. |
| `skill_hints` | `tests/skill_hints.rs:167` | Asserts `argument-hint = "<id> [--period <seconds>] | [--auto]"`. **Unaffected** unless we change the hint text (recommended to keep `[--period <seconds>]` flagged as optional). |
| Wrapper unit tests `select_pulse_period_*` | `src/live/wrapper/mod.rs:1384-1403` | All construct `WrapperState` with explicit `period: 1200`. **Still pass** — they test override consumption, not the default value. |

**New tests needed:**
- `live_start_no_period_argv_omits_pulse_interval` — bare `$LIVE start` produces wrapper argv with period=0, and the inner poll argv does NOT include `--pulse-interval`.
- `live_start_period_60_threads_through` — explicit `--period 60` still produces inner `--pulse-interval 60` (regression guard).
- `wrapper_select_pulse_period_returns_zero_or_none_when_no_pulse` — wrapper state with `period=0` behaves correctly.

No existing test breaks. The behavioral change is invisible to clap-parse tests and the golden suite.

## Recommendation

**Approach:** sentinel `0` = "no pulse" at the wire level, internal type stays `u64` for backward simplicity. Clap default stays `None`; `start::run` converts `None` → `0` instead of `None` → `1200`.

**Reasoning:**
- Minimal type churn (no `u64` → `Option<u64>` cascade through `WrapperState`).
- `0` is currently unreachable (rejected by minimum-60 guard) — repurposing it as a sentinel is safe.
- The argv-omission logic in `poll_psyche` is localized to one site.
- Existing override mechanism (`next_pulse_override: Option<u64>`) keeps working: it overrides the period for ONE iteration (echo-fire micro-pulse use case from `echo_fire.rs:127`), regardless of whether the base period is 0 or >0.

**Suggested change set:**
1. `start.rs:182` — `let period = period.unwrap_or(0);`
2. `start.rs:183-186` — replace minimum-60 guard with: `if period > 0 && period < 60 { … exit(1); }` (allow 0, reject 1..=59).
3. `start.rs:566-569` — mirror.
4. `wrapper/mod.rs:953-964` — switch fixed `[&str; 7]` to a `Vec<&str>` that conditionally appends `--pulse-interval $period`.
5. `wrapper/claude.rs:38-40` — init banner: conditional pulse-period phrasing.
6. `wrapper/claude.rs:16-30` + `psyche.md` — confirm/adjust `{{period}}` token rendering for the `0` case.
7. Skill docs: `live/SKILL.md` + `revive/SKILL.md` — note default = "no scheduled pulses; pulses are opt-in via `--period <seconds>`".
8. CHANGELOG: behavior-change note ("`$LIVE start` no longer pulses by default; pass `--period 1200` to restore prior behavior").
9. Three new tests as listed above.

**Estimated touch:** ~7 files in `src/`, 2 skill docs, 1 CHANGELOG entry, 3 new tests. One round of build + cargo test. One DEPLOY.ps1 cycle.

**Open question for the user:** confirm 0 (sentinel) vs threading `Option<u64>` end-to-end. Sentinel is faster to ship; `Option<u64>` is more type-honest. Either works.

## Sources

All claims sourced from in-repo Rust code at HEAD (2026-05-21):
- `src/cli.rs:11-368` — CLI subcommand definitions
- `src/live/start.rs:181-186, 352-360, 565-569, 641-649, 711-714` — start handler + wrapper spawn
- `src/live/wrapper/mod.rs:520-521, 879-886, 953-1036, 1079-1107` — wrapper state + poll subprocess
- `src/live/wrapper/lifecycle.rs:17-44` — WrapperState constructor
- `src/live/wrapper/claude.rs:13-65` — Claude session init + agents JSON template
- `src/live/wrapper/echo_fire.rs:120-130` — pulse override clamp
- `src/owl/poll.rs:30, 220-221, 337-348` — pulse-interval consumption + PULSE_TRIGGER emit
- `src/live/pulse_wait.rs:9-19` — manual pulse-wait subcommand (unrelated; same minimum-60 guard)
- `src/live/fork.rs:16-25`, `src/live/stop.rs:185-208` — fork/revive delegation
- `tests/cli_parse.rs:342-492`, `tests/golden_live.rs:163`, `tests/file_drop_integration.rs:533`, `tests/skill_hints.rs:167` — test impact
- `plugin/spt/skills/live/SKILL.md`, `plugin/spt/skills/revive/SKILL.md` — user-visible docs
