Full W0 dispatch received (6/6). W1: my env-gate verification resolves the CI condition → both-runner green = full PASS. W0 = priority before W2, but **W0.1 premise-gated to proceed only once W1 full PASS**. So W0 impl is blocked on CI green.

Productive now (read-only): scope W0 from the merged plan + check CI. Read plan + CONTEXT anchors + check CI status.


Bash

git fetch origin main -q 2>&1 | tail -3; gh pr checks 16 2>&1 | head -12

Fetch main + check CI status


n1-gate (self-hosted, Linux, kitsubito)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133270
n1-gate (self-hosted, Windows, hfenduleam)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133145
test (self-hosted, Linux, kitsubito)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133070
test (self-hosted, Windows, hfenduleam)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133295
traceability	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133230


No files found


Plan is on main (PR #15), not my branch. Read it from origin/main. CI still pending.


git show origin/main:DAEMON-LIFECYCLE-COMPLETION-PLAN.md 2>&1 | sed -n '1,200p'

Read W0 plan from origin/main


# Daemon-hosted live-agent lifecycle — completion plan (M11-W0)

> **STATUS: PLANNED (2026-06-15).** JIT plan to *complete* the REQ-DAEMON-1
> consolidation: move the live-agent lifecycle (Psyche spawn, pulse, echo,
> drop-ingest, graceful signoff) out of the interim `api listen` process and
> into the always-on daemon brain, where the RECORDED DESIGN puts it
> (CONTEXT.md §spt-daemon: "all poll-listener logic, **and** all Psyche/pulse
> loops" / §Psyche: "a **loop inside the daemon** ... liveness daemon-held, not
> PID-based" — :30, :34, :38, :168, :177, :194). Rolls into **M11 as W0**
> (runs ahead of / parallel to the shell-substrate waves — orthogonal to them).
> `doyle` designs/gates → `todlando` builds. Heir to `M11-PLAN.md`.

---

## 1. Why now (the reconciliation that surfaced this)

REQ-DAEMON-1 is **partly done, not done**. The daemon-hosted lifecycle
**machinery is built and E2E-tested** (all impl/int-tagged in-tree today):
- `BrainLifecycle` + `run_pulse_loop` (scheduled, config-paced, killable) —
  `spt-daemon/src/lifecycle.rs:260` (impl-tagged).
- the dumb-pipe `Relay` (respawn-safe, no message loss) — `relay.rs` (impl-tagged).
- config-driven pulse period — `config.rs` (impl-tagged).
- `daemon_e2e.rs` (int-tagged) drives spawn→Psyche-loop→commune→
  brain-restart-survives→graceful-signoff.

But the **production wiring is staged, not connected**:
- `brainproc::run_brain` drives **no** lifecycle — `brainproc.rs:175-182`:
  *"Today the supervised daemon brain hosts no PTY sessions ... so this is a
  no-op now and forward-correct when daemon-hosted sessions land (the
  live-agent adapter)."* `run_pulse_loop` has **no production caller**.
- `spt api listen` still runs the **interim in-process** Psyche + pulse —
  `spt/src/api/startup.rs:286,289,323` (`LiveHost::spawn_psyche` + `pulse_tick`
  inside the relay loop), comment: *"interim; M3's daemon re-hosts."*

**Consequences of the interim coupling** (all violate the recorded design):
1. **spt-hosted live agents get no Psyche** — they `bind` (no `listen`), and the
   only spawn path is `cmd_listen`. CONTEXT:177 says spt-hosted Psyche is
   daemon-spawned "same as above"; today there is no such path.
2. **Psyche liveness is relay-process-bound**, not daemon-authoritative —
   contradicts CONTEXT:194 (alive iff the daemon's endpoint table says so).
   A relay swap/kill disturbs the Psyche.
3. The `int` evidence is **type-level, not production-level**: `daemon_e2e`
   constructs `BrainLifecycle` directly; nothing proves the *brain process*
   hosts it for a real endpoint. Green coverage over an unwired production path.

The trigger the brain comment names — *"the live-agent adapter"* — is
**claude-spt** (`perri`), now in build. So completing this unblocks the *correct*
(decoupled, daemon-hosted) live model for the first real consumer, instead of
shipping the interim coupling as the contract.

## 2. Requirements (no new REQ — this completes REQ-DAEMON-1)

REQ-DAEMON-1 already declares `required_stages = ["impl", "unit", "int"]`. This
plan does **not** mint a new id; it **re-points the `int` evidence** from the
type-level `daemon_e2e` to a **production-path** E2E (a real `api listen` / a
real `api bind` whose Psyche is brain-hosted), and adds the missing production
`impl` wiring. Tag the new wiring `// [impl]` on the brain
call-site, the new E2E `// [int]`. (If gate review judges the
production-host path a distinct invariant from the lifecycle machinery, mint
`REQ-DAEMON-1b` register-first — decide at W0.1.)

## 3. Design (settled — CONTEXT.md; do not re-litigate)

- **The brain hosts the lifecycle.** `brainproc::run_brain` owns an endpoint
  table; for each hosted **live** endpoint (manifest resolves `[session.psyche_init]`)
  it instantiates a `BrainLifecycle` and drives `run_pulse_loop` on the
  config-paced deadline. The machinery already exists — this is the *call site*,
  not a rewrite.
- **The relay becomes a true dumb pipe.** `spt api listen` stops spawning the
  Psyche / ticking the pulse; it connects to the brain and streams events
  (`relay::Relay`). All stateful lifecycle leaves the `spt` process (CONTEXT:38).
  `LiveHost`'s lifecycle calls (`spawn_psyche`/`pulse_tick`) are removed; the
  shim becomes pure relay glue or is retired.
- **Both topologies daemon-spawn the Psyche.** harness-hosted (`listen` relay)
  and spt-hosted (`bind`, no relay, direct PTY) both get the brain-hosted Psyche,
  keyed on the endpoint being a LiveAgent — *independent of message delivery*
  (CONTEXT:168, :177).
- **Liveness is daemon-authoritative** (CONTEXT:194) — the brain marks/holds the
  Psyche perch `online`; no relay-process / pid coupling. Brain restart re-hosts
  the lifecycle (the survival the broker/brain split, ADR-0018, already buys).
- **No behavior change for the Self session** — `api boundary`, `api state`,
  commune/signoff file-drops, the digest two-origin merge all keep working; only
  *where the loop runs* changes.

## 4. Task breakdown (waves within W0)

### W0.1 — Brain hosts the lifecycle (the production call site)
- Wire `brainproc::run_brain`'s endpoint table to instantiate `BrainLifecycle`
  for each hosted live endpoint and run `run_pulse_loop` on the config deadline
  (replace the `brainproc.rs:175-182` no-op). Re-hydrate hosted endpoints on
  brain (re)start from disk. `[impl]` `[unit]`
- Decide REQ shape (re-point vs `REQ-DAEMON-1b`) — gate with doyle (§6 Q1).

### W0.2 — Retire the interim relay-hosted Psyche
- Remove `LiveHost::spawn_psyche` / `pulse_tick` from `cmd_listen`
  (`startup.rs:286-346`); `api listen` becomes a pure `relay::Relay` consumer.
  Self-resume / boundary signals route to the brain (IPC), which owns the loop.
  `[impl]`

### W0.3 — spt-hosted live agents get the Psyche
- On `api bind` of a live endpoint (spt-hosted), the brain hosts the Psyche the
  same way — no `listen` required. Close the bind-no-Psyche gap. `[impl/unit]`

### W0.4 — Production-path int (close the type-level-only gap)
- E2E: a real `spt api listen <id>` (and a real `spt api bind <id>` for the
  spt-hosted rung) → the **brain process** spawns + hosts the Psyche → a commune
  drop folds in via the brain's scheduled pulse → **kill the relay**: the Psyche
  stays alive (daemon-authoritative liveness) → **restart the brain**: lifecycle
  re-hosts. Re-point `[int]` here. `[int]`

### W0.5 — Acceptance against the first real live adapter
- Validate with claude-spt `:live` (perri) or the mock live adapter: a live
  session's Psyche is brain-hosted, survives a relay swap, and the Self never
  notices. Coordinate the cutover with perri so her `/sptc:live` lands on the
  decoupled path (not the interim coupling).

## 5. Gate criteria (doyle, per wave + capstone)
- `traceable-reqs check` EXIT 0; REQ-DAEMON-1 `int` re-pointed to the
  production-path E2E (no type-level-only int masking unwired production).
- `run_pulse_loop` has a **production caller** in `brainproc`; `cmd_listen` no
  longer spawns the Psyche (grep proves the interim calls are gone).
- spt-hosted (`bind`, no `listen`) live agent gets a Psyche.
- Psyche liveness daemon-authoritative: relay kill/swap does not drop it.
- Brain restart re-hosts the lifecycle (rides the ADR-0018 survival).
- Full workspace suite green on hfenduleam + kitsubito.

## 6. Open questions for doyle (gate before W0.1)
1. **REQ shape:** re-point REQ-DAEMON-1 `int` + add production `impl`, or mint
   `REQ-DAEMON-1b` for the brain-hosting call site as a distinct invariant?
   Lean: re-point (same requirement, completing its promised production wiring).
2. **Cutover ordering vs perri:** complete W0 *before* perri's `/sptc:live`
   body (she builds against the final decoupled shape), or let her ship on the
   interim `listen`-coupling now and cut over after (no adapter change — she
   declares the seam + runs the relay either way)? Lean: let her proceed on the
   interim (it works), cut over in W0.5 with zero adapter change.
3. **Scope of relay rework:** minimal (strip lifecycle from `cmd_listen`, keep
   its current relay loop) vs full swap to `relay::Relay`. Lean: full swap —
   `relay::Relay` is built + `[impl]`, and the dumb-pipe is the
   recorded design.

## 7. Non-goals
- Re-designing the lifecycle machinery — `BrainLifecycle`/`run_pulse_loop`/
  `Relay` are built + unit-green; this is wiring + retirement, not a rewrite.
- The Psyche I/O / trust-boundary model (ADR-0012) — unchanged.
- Shell-substrate work (M11 W1-W5) — orthogonal; this W0 runs independently.


Full W0 plan clear. doyle's leans: (1) re-point REQ-DAEMON-1 int + add prod impl (no new req), (2) let perri ship interim, cutover W0.5 zero-change, (3) full `relay::Relay` swap. **W0.1 gated until W1 full PASS (CI green)** — can't start impl yet. Ack doyle + read-only scope the W0.1 sites while CI runs.


$OWL send doyle <<'EOF'
[todlando → doyle] FULL W0 DISPATCH RECEIVED (6/6) + plan read from origin/main (DAEMON-LIFECYCLE-COMPLETION-PLAN.md @ PR #15). Understood end-to-end.

ACCEPTED your §6 rulings:
  #1 re-point REQ-DAEMON-1 int to a production-path E2E (real `api listen`/`api bind`, brain-hosted Psyche) + add the production impl at the brainproc call site; NO new req (re-point, not REQ-DAEMON-1b) — the current daemon_e2e int is type-level (builds BrainLifecycle directly) and over-claims; re-pointing fixes the false-green honestly.
  #2 perri proceeds on interim listen-coupling NOW; cutover W0.5, zero adapter change (she declares [session.psyche_init] + runs the relay either way). Won't block her.
  #3 FULL swap to relay::Relay (built + impl-tagged; dumb-pipe is recorded design CONTEXT:38) — not minimal strip-only.

SEQUENCING confirmed: W1 close-out (push+CI) is live — PR #16 both-runner CI pending now. W0 BEFORE W2; W0.1 is the priority. HOLDING W0.1 impl until W1 hits full PASS per your gate. Meanwhile scoping the W0.1 sites read-only (brainproc.rs:175-182 no-op, lifecycle.rs:260 BrainLifecycle/run_pulse_loop, startup.rs:286-346 interim Psyche/pulse, relay::Relay, config pulse-period) so I move on PASS.

Acknowledged W0.4 production-path int is the load-bearing rung (must prove the BRAIN process hosts the Psyche for a REAL endpoint, not BrainLifecycle-in-a-test). Will shout on edges. — todlando
EOF
echo "sent=$?"

Ack full W0 dispatch + confirm sequencing


➜ SENT:doyle
sent=0


150	/// existing `net-status` verb (which also serves as the broker-liveness probe)
151	/// and start them exactly once, on the first `enabled` reply. That single gate
152	/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
153	/// NetHost bind in the background and `net-status` flips `enabled` when it
154	/// attaches, at which point the brain starts the consumers — no extra signal.
155	///
156	/// It inherits the broker's already-unelevated token as a plain child, so it
157	/// runs no de-elevation guard of its own.
158	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
159	pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
160	    let name = broker_socket_name();
161	    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
162	    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
163	    // generation is broker-owned custody of the KH-2.4 counter (no longer the
164	    // brain→brain BrainState.generation, which retires in D4), and the reason is
165	    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
166	    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
167	    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
168	    // *counter* is rehydrated, never the wall-clock stamp.
169	    eprintln!(
170	        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
171	        std::process::id(),
172	        reason.as_arg(),
173	    );
174
175	    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
176	    // continuity from the broker — re-attach EVERY hosted session in resume mode
177	    // from its delivered cursor, the production replacement for the retired
178	    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
179	    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
180	    // so this is a no-op now and forward-correct when daemon-hosted sessions land
181	    // (the live-agent adapter). A failure here is non-fatal — the brain still
182	    // serves; the next heartbeat probes broker liveness.
183	    match brain.resume_sessions() {
184	        Ok(ids) if !ids.is_empty() => {
185	            eprintln!(
186	                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
187	                ids.len()
188	            )
189	        }
190	        Ok(_) => {}
191	        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
192	    }
193
194	    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
195	    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
196	    // not merely "socket connected". The supervisor's readiness gate (D6-2)
197	    // promotes the trial binary on this stamp; promoting on a bare post-connect
198	    // breadcrumb would accept a brain that connected but never rebuilt
199	    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
200	    // this is forward-correct: the gate is defined on the real readiness
201	    // semantic the live-agent adapter inherits.
202	    write_ready(generation);
203
204	    // Shell wake-watchers (restoration D2-2): the offline half of the shell
205	    // online/offline exclusivity — a reconcile loop that runs each offline
206	    // instance's `wake_command` as a supervised watcher child. Relocated from
207	    // the broker process to the brain child: shellwake is brain-owned logic
208	    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
209	    // broker over the same socket IPC for the remote-wake dial. Its boot sweep

/// existing `net-status` verb (which also serves as the broker-liveness probe)
/// and start them exactly once, on the first `enabled` reply. That single gate
/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
/// NetHost bind in the background and `net-status` flips `enabled` when it
/// attaches, at which point the brain starts the consumers — no extra signal.
///
/// It inherits the broker's already-unelevated token as a plain child, so it
/// runs no de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
    // generation is broker-owned custody of the KH-2.4 counter (no longer the
    // brain→brain BrainState.generation, which retires in D4), and the reason is
    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
    // *counter* is rehydrated, never the wall-clock stamp.
    eprintln!(
        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
        std::process::id(),
        reason.as_arg(),
    );

    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
    // continuity from the broker — re-attach EVERY hosted session in resume mode
    // from its delivered cursor, the production replacement for the retired
    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
    // so this is a no-op now and forward-correct when daemon-hosted sessions land
    // (the live-agent adapter). A failure here is non-fatal — the brain still
    // serves; the next heartbeat probes broker liveness.
    match brain.resume_sessions() {
        Ok(ids) if !ids.is_empty() => {
            eprintln!(
                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
                ids.len()
            )
        }
        Ok(_) => {}
        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
    }

    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
    // not merely "socket connected". The supervisor's readiness gate (D6-2)
    // promotes the trial binary on this stamp; promoting on a bare post-connect
    // breadcrumb would accept a brain that connected but never rebuilt
    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
    // this is forward-correct: the gate is defined on the real readiness
    // semantic the live-agent adapter inherits.
    write_ready(generation);

    // Shell wake-watchers (restoration D2-2): the offline half of the shell
    // online/offline exclusivity — a reconcile loop that runs each offline
    // instance's `wake_command` as a supervised watcher child. Relocated from
    // the broker process to the brain child: shellwake is brain-owned logic
    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
    // broker over the same socket IPC for the remote-wake dial. Its boot sweep


brainproc no-op site clear (`run_brain` :175-202, where W0.1 wiring lands). Read the lifecycle machinery + the interim relay site to complete W0.1 scope.


230	            true
231	        } else {
232	            false
233	        };
234	        Ok(TickReport {
235	            ingested,
236	            echo_fired,
237	        })
238	    }
239
240	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
241	    /// each tick (so a caller — or a test — observes cadence and can decide to
242	    /// stop). The period comes from the durable config, never a constant.
243	    ///
244	    /// **Restoration D5-2 (ADR-0018 Q4/V4): the cadence is a disk-anchored
245	    /// absolute-deadline grid, not a phase-relative flat sleep.** The old loop
246	    /// `pulse_tick`-then-`sleep(pulse_period)` silently re-phased the grid to the
247	    /// restart instant on every restart. Now the loop rehydrates a
248	    /// [`DeadlineAnchor`](crate::deadline::DeadlineAnchor) keyed by the session
249	    /// (the daemon pulse uses `"pulse"`; the live-agent adapter's per-agent
250	    /// drivers pass each agent's id — amd 6, no cross-clobber) and applies the
251	    /// [`StartReason`] rule: an `Update` restart **preserves the grid phase**
252	    /// (lands mid-grid), a `Crash`/`Cold` start re-bases it to now. The fire
253	    /// deadline is derived functionally — **no per-fire write**.
254	    ///
255	    /// The loop still holds no work-state beyond the cursor on disk (a fresh loop
256	    /// re-reads the drop dirs and gate), so it is freely killable + respawnable;
257	    /// D5 makes its *phase* durable too.
258	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
259	    // [impl->REQ-DAEMON-1]
260	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
261	        &self,
262	        session_id: Option<&str>,
263	        stop: &AtomicBool,
264	        reason: StartReason,
265	        mut on_tick: F,
266	    ) {
267	        // Keyed anchor (amd 6): per-session file so per-agent pulse drivers never
268	        // cross-clobber. `None` (the single daemon pulse) → the "pulse" key.
269	        let key = session_id.unwrap_or("pulse");
270	        let interval_ms = self.cfg.pulse_period.as_millis() as u64;
271	        // A disk error must not stop the pulse: degrade to an in-memory anchor at
272	        // `now` (fresh-phase, unpersisted) so cadence continues regardless.
273	        let anchor = DeadlineAnchor::open(key, interval_ms, reason, now_ms())
274	            .unwrap_or(DeadlineAnchor {
275	                anchor_ms: now_ms(),
276	                interval_ms: interval_ms.max(1),
277	            });
278	        // The next fire at/after now. A fresh anchor (cold/crash) sits on-grid at
279	        // `now` → fires immediately (the old loop's tick-first behavior); an
280	        // update anchor lands on the next grid point (phase preserved).
281	        let mut deadline = anchor.next_fire(now_ms());
282	        while !stop.load(Ordering::Relaxed) {
283	            let now = now_ms();
284	            if now < deadline {
285	                // Sleep toward the deadline in small slices so a stop is honored
286	                // promptly rather than after a full (possibly multi-second) wait.
287	                sleep_interruptible(Duration::from_millis(deadline - now), stop);
288	                continue;
289	            }
290	            match self.pulse_tick(session_id) {
291	                Ok(report) => on_tick(&report),
292	                Err(_) => { /* a transient tick error must not kill the loop */ }
293	            }
294	            // Advance past the FIRED grid point AND past `now` (doyle amendment
295	            // 7): strictly-after-fired keeps the double-fire guard (a same-ms tick
296	            // steps exactly one grid); skip-past-now collapses a stall (an
297	            // overrunning tick, or a suspend/resume that left `deadline` many
298	            // grids behind) to ONE catch-up fire instead of a back-to-back storm.
299	            deadline = anchor.advance_after_fire(deadline, now_ms());

            true
        } else {
            false
        };
        Ok(TickReport {
            ingested,
            echo_fired,
        })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant.
    ///
    /// **Restoration D5-2 (ADR-0018 Q4/V4): the cadence is a disk-anchored
    /// absolute-deadline grid, not a phase-relative flat sleep.** The old loop
    /// `pulse_tick`-then-`sleep(pulse_period)` silently re-phased the grid to the
    /// restart instant on every restart. Now the loop rehydrates a
    /// [`DeadlineAnchor`](crate::deadline::DeadlineAnchor) keyed by the session
    /// (the daemon pulse uses `"pulse"`; the live-agent adapter's per-agent
    /// drivers pass each agent's id — amd 6, no cross-clobber) and applies the
    /// [`StartReason`] rule: an `Update` restart **preserves the grid phase**
    /// (lands mid-grid), a `Crash`/`Cold` start re-bases it to now. The fire
    /// deadline is derived functionally — **no per-fire write**.
    ///
    /// The loop still holds no work-state beyond the cursor on disk (a fresh loop
    /// re-reads the drop dirs and gate), so it is freely killable + respawnable;
    /// D5 makes its *phase* durable too.
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    // [impl->REQ-DAEMON-1]
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        reason: StartReason,
        mut on_tick: F,
    ) {
        // Keyed anchor (amd 6): per-session file so per-agent pulse drivers never
        // cross-clobber. `None` (the single daemon pulse) → the "pulse" key.
        let key = session_id.unwrap_or("pulse");
        let interval_ms = self.cfg.pulse_period.as_millis() as u64;
        // A disk error must not stop the pulse: degrade to an in-memory anchor at
        // `now` (fresh-phase, unpersisted) so cadence continues regardless.
        let anchor = DeadlineAnchor::open(key, interval_ms, reason, now_ms())
            .unwrap_or(DeadlineAnchor {
                anchor_ms: now_ms(),
                interval_ms: interval_ms.max(1),
            });
        // The next fire at/after now. A fresh anchor (cold/crash) sits on-grid at
        // `now` → fires immediately (the old loop's tick-first behavior); an
        // update anchor lands on the next grid point (phase preserved).
        let mut deadline = anchor.next_fire(now_ms());
        while !stop.load(Ordering::Relaxed) {
            let now = now_ms();
            if now < deadline {
                // Sleep toward the deadline in small slices so a stop is honored
                // promptly rather than after a full (possibly multi-second) wait.
                sleep_interruptible(Duration::from_millis(deadline - now), stop);
                continue;
            }
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Advance past the FIRED grid point AND past `now` (doyle amendment
            // 7): strictly-after-fired keeps the double-fire guard (a same-ms tick
            // steps exactly one grid); skip-past-now collapses a stall (an
            // overrunning tick, or a suspend/resume that left `deadline` many
            // grids behind) to ONE catch-up fire instead of a back-to-back storm.
            deadline = anchor.advance_after_fire(deadline, now_ms());


278
279	    // The bound session id (from the seed) — fills the lifecycle templates.
280	    let session_id = info::read_info(&perch_path)
281	        .map(|i| i.session_id)
282	        .filter(|s| !s.is_empty());
283
284	    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
285	    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
286	    let live = super::live::LiveHost::new_opt(manifest, id);
287	    if let Some(host) = &live {
288	        host.sweep_on_start();
289	        match host.spawn_psyche(session_id.as_deref(), None) {
290	            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
291	            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
292	        }
293	    }
294
295	    eprintln!("READY:{id}");
296
297	    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
298	    // undismissed notifs surface into the spool before the backlog drain, so
299	    // they ride this very first drain — and render endpoint-native when the
300	    // manifest declares [session.notif]. Best-effort — never fails the listen.
301	    super::reporting::resurface_notifs_with(id, manifest);
302
303	    // Deliver one message to the live agent and record it as an spt context
304	    // injection for the two-origin digest (REQ-TERM-7) — `emit` is the
305	    // agent-facing chokepoint, so every delivered message becomes a glanceable
306	    // `owl_message` context entry. The recorded form is the whole composed
307	    // `<EVENT>` (what the agent sees), not the structural pair. Best-effort;
308	    // never disturbs delivery.
309	    let deliver = |from: &str, body: &str| {
310	        emit(from, body);
311	        let event = spt_msg::emit::render_event_whole(from, body);
312	        spt_live::record_context_injection(id, spt_live::KIND_OWL_MESSAGE, &event);
313	    };
314
315	    // Drain the offline backlog first (non-deferred only; deferred rows wait
316	    // for the hook channel).
317	    for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
318	        deliver(&m.from, &m.body);
319	    }
320	    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
321	    let pulse = |host: &Option<super::live::LiveHost>| {
322	        if let Some(h) = host {
323	            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
324	                eprintln!("PULSE_FAIL:{id}: {e}");
325	            }
326	        }
327	    };
328	    if once {
329	        if let Some((from, body)) = listener.check_message_blocking() {
330	            deliver(&from, &body);
331	        }
332	        for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
333	            deliver(&m.from, &m.body);
334	        }
335	        pulse(&live);
336	        return 0;
337	    }
338	    loop {
339	        if let Some((from, body)) = listener.check_message_blocking() {
340	            deliver(&from, &body);
341	        }
342	        for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
343	            deliver(&m.from, &m.body);
344	        }
345	        pulse(&live);
346	    }
347	}
348
349	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
350	/// live perch on first contact, or flips an existing skeleton, keyed to the
351	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
352	/// under a different session id is refused (you can only bind your own).

    // The bound session id (from the seed) — fills the lifecycle templates.
    let session_id = info::read_info(&perch_path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty());

    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
    let live = super::live::LiveHost::new_opt(manifest, id);
    if let Some(host) = &live {
        host.sweep_on_start();
        match host.spawn_psyche(session_id.as_deref(), None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);

    // Deliver one message to the live agent and record it as an spt context
    // injection for the two-origin digest (REQ-TERM-7) — `emit` is the
    // agent-facing chokepoint, so every delivered message becomes a glanceable
    // `owl_message` context entry. The recorded form is the whole composed
    // `<EVENT>` (what the agent sees), not the structural pair. Best-effort;
    // never disturbs delivery.
    let deliver = |from: &str, body: &str| {
        emit(from, body);
        let event = spt_msg::emit::render_event_whole(from, body);
        spt_live::record_context_injection(id, spt_live::KIND_OWL_MESSAGE, &event);
    };

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).
    for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        deliver(&m.from, &m.body);
    }
    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
    let pulse = |host: &Option<super::live::LiveHost>| {
        if let Some(h) = host {
            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
                eprintln!("PULSE_FAIL:{id}: {e}");
            }
        }
    };
    if once {
        if let Some((from, body)) = listener.check_message_blocking() {
            deliver(&from, &body);
        }
        for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            deliver(&m.from, &m.body);
        }
        pulse(&live);
        return 0;
    }
    loop {
        if let Some((from, body)) = listener.check_message_blocking() {
            deliver(&from, &body);
        }
        for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            deliver(&m.from, &m.body);
        }
        pulse(&live);
    }
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).


W0.1/W0.2 scope locked: `run_pulse_loop(session_id, stop, reason, on_tick)` is call-ready (per-agent anchor keyed by session_id); W0.1 wires it into `run_brain` (:175-202 no-op site) per hosted live endpoint + disk re-hydrate; W0.2 strips `startup.rs:286-293/321-345` interim `spawn_psyche`/`pulse_tick` → pure `relay::Relay`. Gated on W1 PASS before impl.

doyle acked, full dispatch + plan absorbed, all §6 rulings accepted. CI pending (slow kitsubito leg). Reschedule — CI green is the unblock (polled on wake).


ScheduleWakeup

Watching PR #16 both-runner CI for W1 full-PASS (unblocks gated W0.1); kitsubito leg ~20-40min. Monitor perch also wakes on any doyle/perri msg.

Prompt: <<autonomous-loop-dynamic>>


<owl_messages>
<owl_message from="doyle" priority="high">aligned, all correct. Holding W0.1 on W1 full-PASS is the right gate. Ping me the PR #16 both-runner green (with shell_actgate_e2e in the run log) and I issue W1 full PASS same-turn → you are clear for W0.1. Read-only scoping meanwhile is good prep. Go.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


Next wakeup scheduled for 06:42:00 (in 1511s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


ACK doyle: aligned. Plan set — on PR #16 both-runner green (with `shell_actgate_e2e` in run log) I ping you → you issue W1 full PASS → I start W0.1. Wakeup polls CI; read-only scope done. Standing by.


# Autonomous loop tick (dynamic pacing)

Run the autonomous check using the loop instructions established earlier in this conversation. If you cannot find them, treat this as a no-op tick.

You scheduled this tick via the ScheduleWakeup tool (not a recurring cron). To keep the loop alive, call ScheduleWakeup again at the end of this turn with `prompt` set to the literal sentinel `<<autonomous-loop-dynamic>>` — otherwise the loop ends after this tick.

If a Monitor is armed (check TaskList), keep `delaySeconds` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a `<task-notification>`, handle the event before rescheduling. To stop the loop, also TaskStop the monitor (use TaskList to find its task ID if no longer in context).


Claude resuming /loop wakeup (Jun 15 6:42am)


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Check PR #16 CI — the W1 unblock gate.


Bash

Check PR 16 CI status


test (self-hosted, Linux, kitsubito)	fail	3m20s	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133070
test (self-hosted, Windows, hfenduleam)	fail	4m28s	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133295
twohost-a	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81433996763
twohost-b	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81433996875
n1-gate (self-hosted, Linux, kitsubito)	pass	1m14s	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133270
n1-gate (self-hosted, Windows, hfenduleam)	pass	1m32s	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133145
traceability	pass	13s	https://github.com/SaberMage/spt-core/actions/runs/27548784334/job/81429133230


Both `test` jobs FAILED (n1-gate + traceability green). Diagnose — pull failing logs.


gh run view 27548784334 --log-failed --job 81429133070 2>&1 | grep -iE "error|fail|panic|FAILED|----" | head -40

Pull linux test failure log


test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:27.1038904Z     Checking thiserror v2.0.18
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:27.8720532Z     Checking n0-error v0.1.3
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:30.6900360Z     Checking thiserror v1.0.69
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:52.4957499Z error: useless use of `vec!`
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:52.6477360Z error: could not compile `spt-daemon` (lib test) due to 1 previous error
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:52.6477879Z warning: build failed, waiting for other jobs to finish...
test (self-hosted, Linux, kitsubito)	Clippy (deny warnings)	2026-06-15T13:19:54.3938055Z ##[error]Process completed with exit code 101.


Failure is **Clippy** (not nextest — so `shell_actgate_e2e` actually ran + passed). `useless use of vec!` in spt-daemon lib test. My local pre-flight was `-p spt` only; CI runs `--workspace` → missed a spt-daemon test lint (latent since T1.2b, first push). Find exact location.


cargo clippy -p spt-daemon --all-targets -- -D warnings 2>&1 | grep -B2 -A8 "useless use of" | head -30

Reproduce vec! clippy lint locally


    Checking spt-live v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
error: useless use of `vec!`
   --> crates\spt-daemon\src\linkhost.rs:702:24
    |
702 |             let args = vec!["note".to_string(), "hi".to_string()];
    |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: you can use an array directly: `["note".to_string(), "hi".to_string()]`
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#useless_vec
    = note: `-D clippy::useless-vec` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::useless_vec)]`


Reproduced: linkhost.rs:702 in the T1.2b act-gate test. Fix to array.


695	            spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();
696
697	            let id = shellinfo::spawn_record(&owlery, "gw", "Gated", None).expect("mint");
698	            // Launch to park a token (so NoLink isn't the refusal reason).
699	            relink_shell(&owlery, "gw", &id).expect("relink parks a token");
700	            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);
701
702	            let args = vec!["note".to_string(), "hi".to_string()];
703	            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
704	            assert!(
705	                matches!(err, ShellLinkError::NeedsApproval(_, false)),
706	                "gated op blocks (always-mode, no persist): {err}"
707	            );
708	            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
709	            assert!(rows.is_empty(), "a refused op must not spool");
710	        });
711	    }
712	}
713

            spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();

            let id = shellinfo::spawn_record(&owlery, "gw", "Gated", None).expect("mint");
            // Launch to park a token (so NoLink isn't the refusal reason).
            relink_shell(&owlery, "gw", &id).expect("relink parks a token");
            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);

            let args = vec!["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_, false)),
                "gated op blocks (always-mode, no persist): {err}"
            );
            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
            assert!(rows.is_empty(), "a refused op must not spool");
        });
    }
}


Edit

            let args = vec!["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();

            let args = ["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();


The file crates\spt-daemon\src\linkhost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

            let args = vec!["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();

            let args = ["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();

relink_shell(&owlery, "gw", &id).expect("relink parks a token");

let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);

-            let args = vec!["note".to_string(), "hi".to_string()];

+            let args = ["note".to_string(), "hi".to_string()];


Verify clippy clean workspace-wide this time (CI runs `--workspace`).


Bash

cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -8

Full workspace clippy (match CI)


    Checking spt-live v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-msg v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-runtime v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-store v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking spt-proto v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Checking xtask v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.48s


Clean workspace-wide. nextest already passed (E2E ran green) — only clippy blocked. Commit fix + push (CI-fix on the PR, clear authorization).


git branch --show-current && git add -A && git commit -q -F - <<'EOF'
M11-W1 fix: useless-vec clippy lint in act-gate unit test (CI --workspace)

The T1.2b `run_action_refuses_gated_op_without_spooling` test built its arg
vector with `vec!` where an array literal suffices — `cargo clippy --workspace
--all-targets -D warnings` (CI) rejects `clippy::useless_vec`. Latent since
T1.2b (the branch's first push surfaced it). Local pre-flight had scoped clippy
to `-p spt`; this was in `spt-daemon`. Fixed to an array; full-workspace clippy
now clean. nextest (incl. shell_actgate_e2e) already passed in the same run —
clippy was the sole red.

Co-authored by: todlando
EOF
git log --oneline -1

Commit clippy fix on branch


m11-shell-substrate
06cf6f1 M11-W1 fix: useless-vec clippy lint in act-gate unit test (CI --workspace)


Push clippy fix to PR branch


To https://github.com/SaberMage/spt-core.git
   23e53fc..06cf6f1  m11-shell-substrate -> m11-shell-substrate


Pushed — CI re-runs on synchronize. Verify new run kicked.


Confirm CI re-triggered


n1-gate (self-hosted, Linux, kitsubito)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401295
n1-gate (self-hosted, Windows, hfenduleam)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401307
test (self-hosted, Linux, kitsubito)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401380
test (self-hosted, Windows, hfenduleam)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401168
traceability	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401318


New CI run pending both runners (clippy now clean → expect green). Actively shepherding the gate — poll tighter to ping doyle promptly on green (CI ~5-8min, harness can't notify on CI).


ScheduleWakeup

Polling PR #16 re-run (clippy fixed) for both-runner green; CI ~5-8min and harness can't notify on it — 270s keeps cache warm to ping doyle same-turn on green.

Prompt: <<autonomous-loop-dynamic>>


Next wakeup scheduled for 06:48:00 (in 227s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


CI red diagnosed + fixed: `useless_vec` clippy lint in the T1.2b act-gate test (linkhost.rs:702). nextest (incl. `shell_actgate_e2e`) had passed — clippy was the sole red, latent since T1.2b because my pre-flight scoped `-p spt` not `--workspace`. Fixed to array, workspace clippy clean, pushed @06cf6f1, CI re-running. Polling for green to ping doyle.


Claude resuming /loop wakeup (Jun 15 6:48am)


Check CI re-run.


Bash

Check PR 16 re-run status


Still pending (~4min in) — runners queued (single slot, shared box). Re-poll.


ScheduleWakeup

PR #16 re-run still pending/queued (shared self-hosted slot); polling for both-runner green to ping doyle same-turn.

Prompt: <<autonomous-loop-dynamic>>


Next wakeup scheduled for 06:52:00 (in 225s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


CI re-run still queued. Polling.


# Autonomous loop tick (dynamic pacing)

Run the autonomous check using the loop instructions established earlier in this conversation. If you cannot find them, treat this as a no-op tick.

You scheduled this tick via the ScheduleWakeup tool (not a recurring cron). To keep the loop alive, call ScheduleWakeup again at the end of this turn with `prompt` set to the literal sentinel `<<autonomous-loop-dynamic>>` — otherwise the loop ends after this tick.

If a Monitor is armed (check TaskList), keep `delaySeconds` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a `<task-notification>`, handle the event before rescheduling. To stop the loop, also TaskStop the monitor (use TaskList to find its task ID if no longer in context).


Claude resuming /loop wakeup (Jun 15 6:52am)


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Bash

Check PR 16 CI status


All still "pending 0" after ~8min — runners not picking up (shared self-hosted box, likely busy). Check run state.


gh run view 27550567354 2>&1 | head -20; echo "---busy?---"; gh api repos/SaberMage/spt-core/actions/runs/27550567354 --jq '.status' 2>&1

Inspect run queue state


* m11-shell-substrate ci SaberMage/spt-core#16 · 27550567354
Triggered via pull_request about 8 minutes ago

JOBS
* test (self-hosted, Windows, hfenduleam) (ID 81435401168)
* n1-gate (self-hosted, Linux, kitsubito) (ID 81435401295)
* n1-gate (self-hosted, Windows, hfenduleam) (ID 81435401307)
* traceability (ID 81435401318)
* test (self-hosted, Linux, kitsubito) (ID 81435401380)

For more information about a job, try: gh run view --job=<job-id>
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27550567354
---busy?---
queued


Run is `queued` (8min, no runner pickup) — self-hosted slot busy (shared box; I'm on hfenduleam). Not a code issue (fix pushed, clippy clean locally). Not my lane to touch runner infra ([[no-machinewide-kill]] binds). Keep polling; back off if still queued next tick.


ScheduleWakeup

PR #16 run queued 8min — self-hosted runner busy; polling for pickup, will back off to 1200s if still queued next tick.

Prompt: <<autonomous-loop-dynamic>>


Next wakeup scheduled for 06:57:00 (in 254s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


CI run queued (runner busy). Polling for pickup.


Claude resuming /loop wakeup (Jun 15 6:57am)


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Bash

Check PR 16 CI status


n1-gate (self-hosted, Windows, hfenduleam)	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401307
traceability	pending	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401318
twohost-a	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81437976048
twohost-b	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81437976118
n1-gate (self-hosted, Linux, kitsubito)	pass	1m17s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401295
test (self-hosted, Linux, kitsubito)	pass	4m55s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401380
test (self-hosted, Windows, hfenduleam)	pass	6m1s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401168


Both `test` jobs **PASS** (linux 4m55s, win 6m1s)! n1-gate-win + traceability still pending. doyle's gate = full suite green + `shell_actgate_e2e` in run log. Confirm the E2E executed (grep log) + wait for last 2 checks.


gh run view 27550567354 --log --job 81435401380 2>&1 | grep -iE "shell_actgate_e2e|act_gate_blocks" | head -5; echo "---remaining---"; gh pr checks 16 2>&1 | grep -iE "pending|fail"

Grep E2E in run log + remaining checks


test (self-hosted, Linux, kitsubito)	Test (nextest)	2026-06-15T13:52:04.8782319Z         PASS [   1.364s] (  24/1064) spt::shell_actgate_e2e act_gate_blocks_command_until_class_scoped_grant
test (self-hosted, Linux, kitsubito)	Test (nextest)	2026-06-15T13:52:06.9459358Z         PASS [   0.009s] ( 290/1064) spt-daemon linkhost::tests::per_capability_act_gate_blocks_until_class_scoped_grant
---remaining---


E2E confirmed in the log: `shell_actgate_e2e ... PASS [1.364s]` on kitsubito — genuinely executed, not skipped. No pending/fail remaining. Confirm full-green.


gh pr checks 16 2>&1 | head -9; echo "---rollup---"; gh pr view 16 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null) | .conclusion] | unique' 2>&1

Confirm all checks green


n1-gate (self-hosted, Windows, hfenduleam)	pass	1m37s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401307
traceability	pass	7s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401318
twohost-a	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81437976048
twohost-b	skipping	0	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81437976118
n1-gate (self-hosted, Linux, kitsubito)	pass	1m17s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401295
test (self-hosted, Linux, kitsubito)	pass	4m55s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401380
test (self-hosted, Windows, hfenduleam)	pass	6m1s	https://github.com/SaberMage/spt-core/actions/runs/27550567354/job/81435401168
---rollup---
["SKIPPED","SUCCESS"]


All green (twohost SKIPPED = expected, no `[twohost]` tag). W1 close-out condition **MET**. Ping doyle with the proof.


$OWL send doyle <<'EOF'
[todlando → doyle] W1 CLOSE-OUT — PR #16 BOTH-RUNNER GREEN. Requesting W1 full PASS.

Run 27550567354 (PR #16, head 06cf6f1):
  test (Linux/kitsubito)   PASS 4m55s
  test (Windows/hfenduleam) PASS 6m1s
  n1-gate Linux PASS · n1-gate Windows PASS · traceability PASS
  twohost SKIPPED (no [twohost] tag — expected). Rollup = {SUCCESS, SKIPPED} only, zero failures.

shell_actgate_e2e IN THE RUN LOG (executed, NOT skipped — your hard condition):
  PASS [1.364s] (24/1064) spt::shell_actgate_e2e act_gate_blocks_command_until_class_scoped_grant
  PASS [0.009s] (290/1064) spt-daemon linkhost::tests::per_capability_act_gate_blocks_until_class_scoped_grant
Ran unconditionally under `cargo nextest run --workspace` (no env gate), exactly as verified.

ONE detour on the way: first run RED at the `clippy --workspace -D warnings` step (NOT nextest — nextest + the E2E passed that run too) — a `clippy::useless_vec` in the T1.2b act-gate unit test (linkhost.rs:702), latent since T1.2b, surfaced on first push (my local pre-flight had scoped clippy to -p spt, missed spt-daemon). Fixed to an array @06cf6f1, full-workspace clippy clean, re-pushed → the green above. Logging the lesson: pre-flight clippy --workspace, not -p.

traceable EXIT 0 at the ref. Awaiting your W1 full PASS → then I start W0.1 (brain hosts the lifecycle). — todlando
EOF
echo "sent=$?"

Ping doyle: PR16 green, request W1 full PASS


➜ SENT:doyle
sent=0


Sent. Save the CI pre-flight lesson (recurring value) + reschedule (now waiting on doyle's PASS via Monitor — CI done).


Write

---
name: ci-clippy-preflight-workspace
description: pre-flight clippy --workspace --all-targets (not -p <crate>) before pushing — CI denies warnings workspace-wide; a -p-scoped local check misses sibling-crate lints
metadata:
  type: feedback
---

CI `test` job runs `cargo clippy --workspace --all-targets -- -D warnings` (ci.yml). A local pre-flight scoped to one crate (`cargo clippy -p spt …`) does NOT catch lints in sibling crates, so a clean local check still eats a CI red round-trip.

Hit on M11-W1 first push of `m11-shell-substrate`: pre-flighted `-p spt`, but a `clippy::useless_vec` in `spt-daemon`'s T1.2b act-gate test (linkhost.rs:702) was latent — surfaced only at CI's `--workspace` step (run RED at clippy, though nextest + the new E2E passed the same run). Fixed to an array @06cf6f1.

**Why:** the lint lived in a crate my `-p` pre-flight didn't compile; CI compiles all.

**How to apply:** before pushing any branch, run `cargo clippy --workspace --all-targets -- -D warnings` (the exact CI invocation), not a `-p`-scoped subset. Same for the nextest sweep when touching multiple crates. Note CI only fires on `pull_request` (push branches = main/dev-freeform), so a feature branch needs a PR to get CI at all. Relates to [[traceable-per-wave-activation]].


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\ci-clippy-preflight-workspace.md (file state is current in your context — no need to Read it back)

---
name: ci-clippy-preflight-workspace
description: pre-flight clippy --workspace --all-targets (not -p <crate>) before pushing — CI denies warnings workspace-wide; a -p-scoped local check misses sibling-crate lints
metadata:
  node_type: memory
  type: feedback
  originSessionId: dcee169a-7209-4b94-8b5d-deba13b0078d
---

CI `test` job runs `cargo clippy --workspace --all-targets -- -D warnings` (ci.yml). A local pre-flight scoped to one crate (`cargo clippy -p spt …`) does NOT catch lints in sibling crates, so a clean local check still eats a CI red round-trip.

Hit on M11-W1 first push of `m11-shell-substrate`: pre-flighted `-p spt`, but a `clippy::useless_vec` in `spt-daemon`'s T1.2b act-gate test (linkhost.rs:702) was latent — surfaced only at CI's `--workspace` step (run RED at clippy, though nextest + the new E2E passed the same run). Fixed to an array @06cf6f1.

**Why:** the lint lived in a crate my `-p` pre-flight didn't compile; CI compiles all.

**How to apply:** before pushing any branch, run `cargo clippy --workspace --all-targets -- -D warnings` (the exact CI invocation), not a `-p`-scoped subset. Same for the nextest sweep when touching multiple crates. Note CI only fires on `pull_request` (push branches = main/dev-freeform), so a feature branch needs a PR to get CI at all. Relates to [[traceable-per-wave-activation]].


ScheduleWakeup

PR #16 fully green + pinged doyle for W1 full PASS; now waiting on his verdict via Monitor perch (CI done) — this is the fallback heartbeat; on PASS I start W0.1.

Prompt: <<autonomous-loop-dynamic>>


Next wakeup scheduled for 07:19:00 (in 1259s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


Add the MEMORY.md index pointer.


1	- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself
2	- [gate against documented design](gate-against-documented-design.md) — gating lesson: verify a wave's premise vs the RECORDED design (CONTEXT.md/ADRs), not just current impl — an impl limitation can contradict a locked design (W2.5 single-subscriber vs locked controller/viewer model; operator caught it)
3	- [grep REQ tags to find impl](grep-req-tags-to-find-impl.md) — operator 2026-06-15: to reconcile is-REQ-X-wired, grep `REQ-X` FIRST — the [impl/int->REQ-X] tags point straight to the code; faster + more authoritative than an agent trace (I burned an agent trace + overstated "false-green" on REQ-DAEMON-1; tags showed it's built+E2E-tested, production-activation staged behind the live-agent adapter)

- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself
- [gate against documented design](gate-against-documented-design.md) — gating lesson: verify a wave's premise vs the RECORDED design (CONTEXT.md/ADRs), not just current impl — an impl limitation can contradict a locked design (W2.5 single-subscriber vs locked controller/viewer model; operator caught it)
- [grep REQ tags to find impl](grep-req-tags-to-find-impl.md) — operator 2026-06-15: to reconcile is-REQ-X-wired, grep `REQ-X` FIRST — the [impl/int->REQ-X] tags point straight to the code; faster + more authoritative than an agent trace (I burned an agent trace + overstated "false-green" on REQ-DAEMON-1; tags showed it's built+E2E-tested, production-activation staged behind the live-agent adapter)


Edit

- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md)

- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)
- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md)


The file C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)
- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md)

+- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)

- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself

- [gate against documented design](gate-against-documented-design.md) — gating lesson: verify a wave's premise vs the RECORDED design (CONTEXT.md/ADRs), not just current impl — an impl limitation can contradict a locked design (W2.5 single-subscriber vs locked controller/viewer model; operator caught it)

- [grep REQ tags to find impl](grep-req-tags-to-find-impl.md) — operator 2026-06-15: to reconcile is-REQ-X-wired, grep `REQ-X` FIRST — the [impl/int->REQ-X] tags point straight to the code; faster + more authoritative than an agent trace (I burned an agent trace + overstated "false-green" on REQ-DAEMON-1; tags showed it's built+E2E-tested, production-activation staged behind the live-agent adapter)


**W1 close-out done — PR #16 fully green.**
- Both `test` jobs PASS (kitsubito 4m55s, hfenduleam 6m1s); n1-gate ×2 + traceability PASS; twohost SKIPPED (expected). Rollup = SUCCESS/SKIPPED, zero failures.
- `shell_actgate_e2e` confirmed **in the run log** PASS [1.364s] — executed unconditionally, not skipped (doyle's hard condition).
- Detour: first run RED at `clippy --workspace` (`useless_vec` in T1.2b test) — fixed @06cf6f1, re-green. Lesson saved to memory.

Pinged doyle with proof → awaiting his W1 full PASS (same-turn) → then clear for **W0.1** (brain hosts the lifecycle). Standing by.


<task-notification>
<task-id>bmby1p3hm</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/6" id="d8fce58c" type="msg" from="doyle">[doyle → todlando] ✅ W1 FULL PASS — REQ-CONSENT-3 CLOSED.

Independently verified (not trust-the-report): run 27550567354 conclusion=success, head 06cf6f1 — test(Linux)+test(Windows)+n1-gate(both)+traceability ALL green, twohost skipped (expected, no tag), zero failures. shell_actgate_e2e executed uncondit</EVENT-PART>
<EVENT-PART seq="2/6" id="d8fce58c">ionally + PASS in the log (my hard condition met), and I'd already verified its assertions by reading it (CONSENT_PENDING refusal, no-spool/evidence-empty, wrong-class refuses, right-class flips through, real binary). traceable EXIT 0. All five §5 W1 criteria satisfied.

So the full M11-W1 close-out: design matches CONTEXT:283, class sc</EVENT-PART>
<EVENT-PART seq="3/6" id="d8fce58c">ope real end-to-end, no-spool proven, floor honored, both-runner CI green with the E2E actually running. PASS stands unconditional now.

clippy lesson logged + agreed: pre-flight `clippy --workspace --all-targets -D warnings`, never `-p &lt;crate&gt;` — workspace warnings hide in sibling crates (the linkhost.rs:702 useless_vec was late</EVENT-PART>
<EVENT-PART seq="4/6" id="d8fce58c">nt since T1.2b, only surfaced full-workspace). Good catch + clean fix @06cf6f1.

CLEARED FOR W0.1 (brain hosts the lifecycle) on the gated premise (#1 re-point int / #2 perri-on-interim / #3 full relay::Relay swap). Your read-only scoping of the sites (brainproc no-op, lifecycle.rs:260, startup.rs:286-346, relay::Relay, config) is the ri</EVENT-PART>
<EVENT-PART seq="5/6" id="d8fce58c">ght prep. Remember W0.4 is the load-bearing rung — the production-path int must prove the BRAIN process hosts the Psyche for a REAL endpoint (api listen / api bind), not BrainLifecycle-in-a-test. I gate each W0 wave at evidence-land. Go.

(FYI: v0.7.2 just tagged @1582e32 — your m11-shell-substrate work is unaffected; PR #16 stays yo</EVENT-PART>
<EVENT-PART seq="6/6" id="d8fce58c">ur W1 close-out vehicle, merge it or keep it as the CI record per your M11 branch flow.)</EVENT-PART></event>
</task-notification>


✅ **W1 FULL PASS — REQ-CONSENT-3 CLOSED.** Cleared for W0.1 on the gated premise (re-point int / perri-interim / full relay::Relay). doyle gates each W0 wave; W0.4 load-bearing. v0.7.2 tagged @1582e32 (my branch unaffected; PR #16 stays the W1 record).

Starting W0.1 (brain hosts the lifecycle). Ack doyle + dig into the precise call-site: `BrainLifecycle` constructor, `run_brain` session model, `resume_sessions`, `[session.psyche_init]` resolution.


Bash

$OWL send doyle <<'EOF'
[todlando → doyle] W1 FULL PASS RECEIVED — REQ-CONSENT-3 CLOSED. Thanks for the independent verify. Clippy lesson logged to memory (pre-flight --workspace, never -p).

STARTING W0.1 now (brain hosts the lifecycle) on your gated premise: #1 re-point REQ-DAEMON-1 int + add production impl (no new req), #2 perri stays on interim (won't block), #3 FULL relay::Relay swap (W0.2). W0.1 scope = wire brainproc::run_brain's session/endpoint hosting to instantiate BrainLifecycle per hosted LIVE endpoint ([session.psyche_init] resolves) + drive run_pulse_loop on the config deadline + re-hydrate hosted endpoints from disk on brain (re)start — replacing the brainproc.rs:175-182 no-op. [impl][unit]. W0.4 production-path int is the load-bearing rung (brain process hosts the Psyche for a REAL api listen/api bind endpoint, not BrainLifecycle-in-a-test) — kept front of mind.

PR #16 stays the W1 close-out / CI record on m11-shell-substrate; W0 commits ride the same branch. Will submit W0.1 for your wave gate at evidence-land. — todlando
EOF
echo sent=$?

Ack W1 PASS + announce W0.1 start


209	    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
210	    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
211	    // children from disk — exactly the Q5 "re-reconciled on brain start"
212	    // posture. The stop flag is held for symmetry, never raised (the watchers
213	    // die with the brain process and the next brain re-sweeps them).
214	    // [impl->REQ-SHELL-2]
215	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
216	    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));
217
218	    let mut consumers_started = false;
219	    loop {
220	        thread::sleep(BRAIN_HEARTBEAT);
221	        // `net-status` is both the net-capability probe and the broker-liveness
222	        // probe: an `Err` is the broker gone (or a future planned
223	        // snapshot+self-exit, Q3) — exit for a supervised respawn.
224	        let status = match brain.net_status() {
225	            Ok(s) => s,
226	            Err(e) => {
227	                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
228	                return Err(e);
229	            }
230	        };
231	        if consumer_gate(status.enabled, consumers_started) {
232	            let hex = status.node_id_hex.clone().unwrap_or_default();
233	            spawn_net_consumers(&hex);
234	            consumers_started = true;
235	            eprintln!(
236	                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
237	            );
238	        }
239	        write_ready(generation);
240	    }
241	}
242
243	/// Whether to spawn the net consumers this heartbeat: only once the broker
244	/// reports a net endpoint up (`net_enabled`) AND we have not already started
245	/// them. Pure for the unit table — the spawn-exactly-once + net-gating
246	/// semantics that keep a single dispatcher/peer-pump set running.
247	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
248	fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
249	    net_enabled && !already_started
250	}
251
252	/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
253	/// (D9-1) — against the broker's socket. **Relocated from the broker process to
254	/// the brain child (restoration D2-1):** both are standalone IPC clients
255	/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
256	/// restartable brain. Each mints its own registry + stop flag and connects
257	/// independently; when the brain process exits these in-process threads die with
258	/// it, and the respawned brain re-spawns them from the current binary. Mirrors
259	/// the wiring the broker boot used to do, `node_hex` now sourced from the
260	/// `net-status` reply rather than the broker's identity load.
261	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
262	// [impl->REQ-DAEMON-9]
263	fn spawn_net_consumers(node_hex: &str) {
264	    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
265	    let stop = Arc::new(AtomicBool::new(false));
266	    let _ = crate::dispatch::spawn_dispatcher(
267	        broker_socket_name(),
268	        Arc::clone(&registry),
269	        Arc::clone(&stop),
270	    );
271	    let cfg = crate::config::DaemonConfig::load();
272	    if cfg.peer_pump {
273	        let _ = crate::pump::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
274	    }
275	}
276
277	/// Connect to the broker, retrying briefly while it finishes binding.
278	fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
279	    let mut last: Option<io::Error> = None;
280	    for _ in 0..tries {
281	        match Brain::cold_start(name, now_ms()) {
282	            Ok(b) => return Ok(b),
283	            Err(e) => {
284	                last = Some(e);
285	                thread::sleep(Duration::from_millis(10));
286	            }
287	        }
288	    }
289	    Err(last.unwrap_or_else(|| io::Error::other("brain could not connect to broker")))
290	}
291
292	/// Write the readiness breadcrumb (best-effort), restoration D6-1b + D7-1: a
293	/// self-describing JSON `{pid, generation, exe_hash}`. A supervisor/test reads
294	/// the `pid` to confirm the brain is up and (on a change) that it respawned; the
295	/// `generation` is the **readiness gate's** key (ADR-0018 Q7, D6-2) — the
296	/// supervisor that spawned generation N promotes only on a ready stamp **== N**,
297	/// so a stale stamp from a prior generation cannot falsely promote a brain that
298	/// never booted. **`exe_hash` (D7-1)** is the lowercase-hex SHA-256 of *this

    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
    // children from disk — exactly the Q5 "re-reconciled on brain start"
    // posture. The stop flag is held for symmetry, never raised (the watchers
    // die with the brain process and the next brain re-sweeps them).
    // [impl->REQ-SHELL-2]
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));

    let mut consumers_started = false;
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        // `net-status` is both the net-capability probe and the broker-liveness
        // probe: an `Err` is the broker gone (or a future planned
        // snapshot+self-exit, Q3) — exit for a supervised respawn.
        let status = match brain.net_status() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
                return Err(e);
            }
        };
        if consumer_gate(status.enabled, consumers_started) {
            let hex = status.node_id_hex.clone().unwrap_or_default();
            spawn_net_consumers(&hex);
            consumers_started = true;
            eprintln!(
                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
            );
        }
        write_ready(generation);
    }
}

/// Whether to spawn the net consumers this heartbeat: only once the broker
/// reports a net endpoint up (`net_enabled`) AND we have not already started
/// them. Pure for the unit table — the spawn-exactly-once + net-gating
/// semantics that keep a single dispatcher/peer-pump set running.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
    net_enabled && !already_started
}

/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the broker's socket. **Relocated from the broker process to
/// the brain child (restoration D2-1):** both are standalone IPC clients
/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
/// restartable brain. Each mints its own registry + stop flag and connects
/// independently; when the brain process exits these in-process threads die with
/// it, and the respawned brain re-spawns them from the current binary. Mirrors
/// the wiring the broker boot used to do, `node_hex` now sourced from the
/// `net-status` reply rather than the broker's identity load.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
    let stop = Arc::new(AtomicBool::new(false));
    let _ = crate::dispatch::spawn_dispatcher(
        broker_socket_name(),
        Arc::clone(&registry),
        Arc::clone(&stop),
    );
    let cfg = crate::config::DaemonConfig::load();
    if cfg.peer_pump {
        let _ = crate::pump::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
    }
}

/// Connect to the broker, retrying briefly while it finishes binding.
fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
    let mut last: Option<io::Error> = None;
    for _ in 0..tries {
        match Brain::cold_start(name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => {
                last = Some(e);
                thread::sleep(Duration::from_millis(10));
            }
        }
    }
    Err(last.unwrap_or_else(|| io::Error::other("brain could not connect to broker")))
}

/// Write the readiness breadcrumb (best-effort), restoration D6-1b + D7-1: a
/// self-describing JSON `{pid, generation, exe_hash}`. A supervisor/test reads
/// the `pid` to confirm the brain is up and (on a change) that it respawned; the
/// `generation` is the **readiness gate's** key (ADR-0018 Q7, D6-2) — the
/// supervisor that spawned generation N promotes only on a ready stamp **== N**,
/// so a stale stamp from a prior generation cannot falsely promote a brain that
/// never booted. **`exe_hash` (D7-1)** is the lowercase-hex SHA-256 of *this


run_brain structure clear: connect → resume_sessions (no-op) → write_ready → shellwake → heartbeat loop. W0.1 wiring lands at the resume/start + needs per-endpoint pulse drivers. Read `BrainLifecycle` (struct/new/pulse_tick) + `resume_sessions` + how live endpoints enumerate from disk.


1	//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
2	//! interim `api listen` runner's live half.
3	//!
4	//! In the no-daemon interim model the long-lived `spt api listen` process hosted
5	//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
6	//! graceful signoff) directly in the agent's session. Consolidation
7	//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
8	//! brain runs it as scheduled in-process loops, with a durable, configurable
9	//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
10	//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
11	//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
12	//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
13	//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
14	//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
15	//! where they always have.
16	// [impl->REQ-DAEMON-1]
17
18	use std::collections::BTreeMap;
19	use std::path::{Path, PathBuf};
20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::proc::is_process_alive;
30
31	use crate::brainproc::StartReason;
32	use crate::config::DaemonConfig;
33	use crate::deadline::DeadlineAnchor;
34
35	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
36	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
37
38	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
39	fn now_ms() -> u64 {
40	    SystemTime::now()
41	        .duration_since(UNIX_EPOCH)
42	        .map(|d| d.as_millis() as u64)
43	        .unwrap_or(0)
44	}
45
46	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
47	fn now_stamp() -> String {
48	    format!("epoch-ms:{}", now_ms())
49	}
50
51	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
52	#[derive(Debug, Clone, Default)]
53	pub struct TickReport {
54	    pub ingested: Vec<Ingested>,
55	    pub echo_fired: bool,
56	}
57
58	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
59	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
60	pub struct BrainLifecycle {
61	    runtime: ManifestRuntime,
62	    id: String,
63	    /// The project this lifecycle's context routes under (two-tier split,
64	    /// REQ-INST-2): derived once from the watched commune dir's parent — the
65	    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
66	    /// is the project root — falling back to the daemon's cwd for a manifest
67	    /// without watched dirs.
68	    project_id: String,
69	    manifest: Manifest,
70	    commune_dir: Option<PathBuf>,
71	    signoff_dir: Option<PathBuf>,
72	    cfg: DaemonConfig,
73	}
74
75	impl BrainLifecycle {
76	    /// Build a host for `id` from a live-capable manifest (declares
77	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
78	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
79	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
80	        Self::with_config(manifest?, id, DaemonConfig::load())
81	    }
82
83	    /// Build a host from a live-capable manifest with an explicit config (the
84	    /// testable seam). `None` if the manifest declares no Psyche.
85	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
86	        manifest.session.psyche_init.as_ref()?;
87	        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
88	        // [impl->REQ-INST-2] project identity for the two-tier context split:
89	        // the commune drop dir's parent is the project root; cwd is the
90	        // fallback for a manifest without watched dirs.
91	        let project_anchor = commune_dir
92	            .as_ref()
93	            .and_then(|d| d.parent().map(Path::to_path_buf))
94	            .or_else(|| std::env::current_dir().ok())
95	            .unwrap_or_else(|| PathBuf::from("."));
96	        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
97	        Some(BrainLifecycle {
98	            runtime: ManifestRuntime::new(manifest.clone()),
99	            id: id.to_string(),
100	            project_id,
101	            commune_dir,
102	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
103	            manifest: manifest.clone(),
104	            cfg,
105	        })
106	    }
107
108	    /// The configured pulse period (config-driven, not a constant).
109	    pub fn pulse_period(&self) -> Duration {
110	        self.cfg.pulse_period
111	    }
112
113	    /// Base substitution keys spt-core fills for the lifecycle templates.
114	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
115	        let mut k = BTreeMap::new();
116	        k.insert("id".to_string(), self.id.clone());
117	        if let Some(sid) = session_id {
118	            k.insert("session_id".to_string(), sid.to_string());
119	        }
120	        k
121	    }
122
123	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
124	    fn drop_dirs(&self) -> Vec<PathBuf> {
125	        let mut dirs: Vec<PathBuf> = Vec::new();
126	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
127	            .into_iter()
128	            .flatten()
129	        {
130	            if !dirs.contains(d) {

//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::proc::is_process_alive;

use crate::brainproc::StartReason;
use crate::config::DaemonConfig;
use crate::deadline::DeadlineAnchor;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    /// The project this lifecycle's context routes under (two-tier split,
    /// REQ-INST-2): derived once from the watched commune dir's parent — the
    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
    /// is the project root — falling back to the daemon's cwd for a manifest
    /// without watched dirs.
    project_id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
        // [impl->REQ-INST-2] project identity for the two-tier context split:
        // the commune drop dir's parent is the project root; cwd is the
        // fallback for a manifest without watched dirs.
        let project_anchor = commune_dir
            .as_ref()
            .and_then(|d| d.parent().map(Path::to_path_buf))
            .or_else(|| std::env::current_dir().ok())
            .unwrap_or_else(|| PathBuf::from("."));
        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            project_id,
            commune_dir,
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {


`BrainLifecycle::new_opt(manifest, id)` → Some iff `[session.psyche_init]`. Read the rest (pulse_tick, any psyche-spawn/signoff methods) + how `daemon_e2e` drives it (the type-level int I'll re-point) + `resume_sessions`.


130	            if !dirs.contains(d) {
131	                dirs.push(d.clone());
132	            }
133	        }
134	        dirs
135	    }
136
137	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
138	    pub fn sweep_on_start(&self) {
139	        if let Some(dir) = &self.signoff_dir {
140	            let _ = sweep_stale_signoff(dir, &self.id);
141	        }
142	    }
143
144	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
145	    /// spawn is event-driven; `None` at cold start.
146	    ///
147	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
148	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
149	    /// after binding, mark the perch `online` — from here liveness reads the
150	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
151	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
152	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
153	    pub fn spawn_psyche(
154	        &self,
155	        session_id: Option<&str>,
156	        event: Option<&str>,
157	    ) -> Result<PsycheHandle, String> {
158	        let handle = spawn_psyche(
159	            &self.runtime,
160	            &self.id,
161	            &self.base_keys(session_id),
162	            event,
163	            &now_stamp(),
164	        )
165	        .map_err(|e| e.to_string())?;
166	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
167	            .map_err(|e| format!("mark psyche online: {e}"))?;
168	        Ok(handle)
169	    }
170
171	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
172	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
173	    /// the single liveness resolver reports the perch dead regardless of any pid
174	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
175	    /// (perch already gone) is not an error.
176	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
177	    pub fn mark_offline(perch_path: &std::path::Path) {
178	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
179	    }
180
181	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
182	    /// signal the consent gate resolves "most-recently-active live session"
183	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
184	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
185	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
186	    // [impl->REQ-UPD-4]
187	    pub fn touch_active(&self) {
188	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
189	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
190	    }
191
192	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
193	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
194	    /// echo-commune (T3).
195	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
196	        let now = now_ms();
197	        // Recency follows the resting model (D9-2 — the D8 gap closed): only
198	        // an ACTIVE (or pre-D9-2 recordless) instance refreshes
199	        // `last_active_ms` — a resting seat ticking its pulse must never win
200	        // a "most-recently-active" resolution (REQ-UPD-4's consent target,
201	        // the notif first-fire). A dormant seat instead checks the opt-in
202	        // auto-suspend threshold (counted from its dormancy-onset anchor);
203	        // a suspended seat just keeps ingesting.
204	        // [impl->REQ-INST-3]
205	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
206	        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
207	            None | Some(crate::resting::RestState::Active) => self.touch_active(),
208	            Some(crate::resting::RestState::Dormant) => {
209	                let _ = self.rest_event(
210	                    session_id,
211	                    crate::resting::RestEvent::AutoSuspendTick { now_ms: now },
212	                );
213	            }
214	            Some(crate::resting::RestState::Suspended) => {}
215	        }
216	        let mut ingested = Vec::new();
217	        for dir in self.drop_dirs() {
218	            let mut got = ingest_drops(
219	                &dir,
220	                &self.id,
221	                &self.project_id,
222	                now,
223	                self.cfg.protection_window_ms,
224	            )
225	            .map_err(|e| e.to_string())?;
226	            ingested.append(&mut got);
227	        }
228	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
229	            self.fire_echo(session_id)?;
230	            true
231	        } else {
232	            false
233	        };
234	        Ok(TickReport {

            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    ///
    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
    /// after binding, mark the perch `online` — from here liveness reads the
    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        let handle = spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
    /// the single liveness resolver reports the perch dead regardless of any pid
    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
    /// (perch already gone) is not an error.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn mark_offline(perch_path: &std::path::Path) {
        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
    }

    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
    /// signal the consent gate resolves "most-recently-active live session"
    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
    // [impl->REQ-UPD-4]
    pub fn touch_active(&self) {
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
    }

    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
    /// echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        let now = now_ms();
        // Recency follows the resting model (D9-2 — the D8 gap closed): only
        // an ACTIVE (or pre-D9-2 recordless) instance refreshes
        // `last_active_ms` — a resting seat ticking its pulse must never win
        // a "most-recently-active" resolution (REQ-UPD-4's consent target,
        // the notif first-fire). A dormant seat instead checks the opt-in
        // auto-suspend threshold (counted from its dormancy-onset anchor);
        // a suspended seat just keeps ingesting.
        // [impl->REQ-INST-3]
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
            None | Some(crate::resting::RestState::Active) => self.touch_active(),
            Some(crate::resting::RestState::Dormant) => {
                let _ = self.rest_event(
                    session_id,
                    crate::resting::RestEvent::AutoSuspendTick { now_ms: now },
                );
            }
            Some(crate::resting::RestState::Suspended) => {}
        }
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got = ingest_drops(
                &dir,
                &self.id,
                &self.project_id,
                now,
                self.cfg.protection_window_ms,
            )
            .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport {


crates\spt-daemon\tests\daemon_e2e.rs:10://! 2. **Psyche-loop** — a [`BrainLifecycle`] (the consolidated re-host of the
crates\spt-daemon\tests\daemon_e2e.rs:47:use spt_daemon::{Brain, BrainLifecycle, Broker, DaemonConfig};
crates\spt-daemon\tests\daemon_e2e.rs:136:    let host = BrainLifecycle::with_config(&manifest, id, cfg).expect("live host");
crates\spt-daemon\tests\daemon_e2e.rs:165:        .spawn_psyche(Some("sid-1"), Some("pulse"))
crates\spt-daemon\tests\daemon_e2e.rs:286:    BrainLifecycle::mark_offline(&psyche.perch_path);


Complete `BrainLifecycle` API mapped (sweep_on_start, spawn_psyche, mark_offline, touch_active, pulse_tick, run_pulse_loop). `daemon_e2e` builds it directly via `with_config` — the type-level int to re-point. Now the key W0.1 design Q: how the brain enumerates hosted live endpoints + resolves their manifest. Read `cmd_listen`'s manifest resolution (the pattern to mirror) + `resume_sessions`.


<owl_messages>
<owl_message from="doyle" priority="high">ack — W0.1 scope is exactly right: brainproc::run_brain instantiates BrainLifecycle per hosted live endpoint ([session.psyche_init] resolves) + drives run_pulse_loop on the config deadline + re-hydrates on brain (re)start, replacing the 175-182 no-op. Go. I gate W0.1 at evidence-land vs CONTEXT:30/34/194 (daemon-brain owns the loop; liveness daemon-authoritative). W0.4 production-path int stays the load-bearing rung — don't let it regress to BrainLifecycle-in-a-test. W0 commits on m11-shell-substrate (PR #16 record) is fine. Submit at evidence-land; shout on edges.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.